Decoding and encoding
Accepted input
Section titled “Accepted input”decode takes any of three shapes, so you rarely have to convert anything:
products.decode("?search=vue&page=2"); // a query string, with or without "?"products.decode(new URLSearchParams(location.search)); // any Iterable<[key, value]>products.decode({ search: "vue", page: "2" }); // a plain objectproducts.decode({ tag: ["a", "b"] }); // repeated values as an arrayIn the object form, a key whose value is undefined is treated as absent rather than as an empty
value.
The result
Section titled “The result”type DecodeResult<T> = | { ok: true; value: T; issues: readonly QueryIssue[] } | { ok: false; partial: Partial<T>; issues: readonly QueryIssue[] };ok: true means a complete, usable state was produced. It does not mean nothing went wrong —
a recovered value still reports its issue:
const result = products.decode("?page=0"); // page has min: 1
result.ok; // trueif (result.ok) result.value.page; // 1 — recovered to the declared defaultresult.issues[0]?.code; // "out_of_range"ok: false means no safe whole could be produced, which happens when a required parameter is
missing or invalid. partial then carries the keys that did decode, so a caller can render
diagnostics without inventing values.
const strict = defineQueryModel({ category: param.text(), page: param.integer().default(1),});const failed = strict.decode("?page=3");
failed.ok; // falseif (!failed.ok) { failed.partial; // { page: 3 } failed.issues[0]?.code; // "missing"}Encoding
Section titled “Encoding”encode takes a complete typed state and returns canonical entries:
const values = { search: "wireless headphones", page: 2, sort: "price", tags: [],} as const;
const output = products.encode(values);// [["search", "wireless headphones"], ["page", "2"], ["sort", "price"]]Three rules decide the output:
- Definition order. Keys are emitted in the order the model declares them, not the order they appeared in the input. Two equal states therefore produce byte-identical strings.
- Defaults are omitted. A value whose encoding equals the encoding of its declared default is
dropped.
tags: []disappears above because[]is the declared default. - Absence emits nothing.
undefinedproduces no entry at all;nullproduces an empty value if the parameter is nullable, and nothing otherwise.
The comparison in rule 2 is made on encoded values rather than on the values themselves, which is why it works for arrays and objects without needing a deep-equality helper.
Canonical form
Section titled “Canonical form”import { formatQueryString } from "@queryweave/core";
const values = { search: "wireless headphones", page: 2, sort: "price", tags: [],} as const;
const query = formatQueryString(products.encode(values));query; // "search=wireless+headphones&page=2&sort=price"Canonical output is the shortest encoding that decodes back to the same state. Its practical value is that URLs become comparable: caches, analytics, and equality checks all stop seeing three spellings of one state.
Normalization
Section titled “Normalization”normalize decodes and re-encodes in one step, which is how you clean an incoming URL:
const incoming = "?page=1&sort=created_at&search=vue&unknown=1";const normalized = products.normalize(incoming);
normalized; // [["search", "vue"]]page and sort were explicitly set to their defaults, so they are dropped. unknown is dropped
too, because normalize is pure model output — the model does not manage that key and has nothing
to say about it. If you need unmanaged keys preserved, use the runtime or
createQueryUrl, both of which re-attach them.
When decoding fails, normalize still produces output: it merges defaults with the partial values
and encodes that, so a broken URL normalizes to the closest valid one rather than to nothing.
The path a query takes through the model, stage by stage.
- Raw query — ?search=vue&page=2
- decode()
- Typed state — { search: "vue", page: 2 }
- encode()
- Canonical query — search=vue&page=2
Raw query → decode(). decode() → Typed state. Typed state → encode(). encode() → Canonical query.
Asynchronous decoding
Section titled “Asynchronous decoding”const result = await products.decodeAsync("?search=wireless&page=2");
result.ok; // trueif (result.ok) result.value.page; // 2Use it when any parameter or the model itself uses an asynchronous refinement — typically an asynchronous Standard Schema validator. Parameters decode concurrently; model-level refinements run in sequence, because each one sees the previous one’s output.
Calling the synchronous decode on a model with asynchronous validation is not a crash: it reports
an async_required issue and recovers. Neither decode nor decodeAsync ever throws or rejects
for a value; a codec or refinement that throws becomes an issue.
Edge cases
Section titled “Edge cases”- A malformed percent sequence such as
?q=%E0%A4%Ais kept verbatim rather than throwing, and only that sequence:?q=50%+offstill reads50% off. - A key with no
=(?flag) decodes as an empty value for that key. - Repeated keys for a single-value parameter report
unexpected_multiple_valuesand use the first. - An empty query decodes to the model’s defaults, with no issues.
How it is tested
Section titled “How it is tested”tests/core/query-input.test.ts covers parsing and formatting against URLSearchParams;
tests/core/model.test.ts covers results, ordering, and omission; tests/core/round-trip.test.ts
asserts decode-encode stability.
Defaults and absence explains why omission is a semantic rule rather than a size optimization.