Skip to content
Runtime
Framework

Decoding and encoding

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 object
products.decode({ tag: ["a", "b"] }); // repeated values as an array

In the object form, a key whose value is undefined is treated as absent rather than as an empty value.

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; // true
if (result.ok) result.value.page; // 1 — recovered to the declared default
result.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; // false
if (!failed.ok) {
failed.partial; // { page: 3 }
failed.issues[0]?.code; // "missing"
}

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:

  1. 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.
  2. Defaults are omitted. A value whose encoding equals the encoding of its declared default is dropped. tags: [] disappears above because [] is the declared default.
  3. Absence emits nothing. undefined produces no entry at all; null produces 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.

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.

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.

  1. Raw query — ?search=vue&page=2
  2. decode()
  3. Typed state — { search: "vue", page: 2 }
  4. encode()
  5. Canonical query — search=vue&page=2

Raw query → decode(). decode() → Typed state. Typed state → encode(). encode() → Canonical query.

Decode recovers and reports; encode canonicalizes and omits. Normalization is the two composed.
const result = await products.decodeAsync("?search=wireless&page=2");
result.ok; // true
if (result.ok) result.value.page; // 2

Use 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.

  • A malformed percent sequence such as ?q=%E0%A4%A is kept verbatim rather than throwing, and only that sequence: ?q=50%+off still reads 50% off.
  • A key with no = (?flag) decodes as an empty value for that key.
  • Repeated keys for a single-value parameter report unexpected_multiple_values and use the first.
  • An empty query decodes to the model’s defaults, with no issues.

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.