Codecs
A QueryCodec converts raw string values into one typed value and back again. Both directions live
in the same object, which is the reason a QueryWeave parameter cannot drift the way a separate
parser and serializer can.
The contract
Section titled “The contract”interface QueryCodec<TValue> { decode(input: readonly string[], context: QueryDecodeContext): QueryValueResult<TValue>; decodeAsync?( input: readonly string[], context: QueryDecodeContext, ): Promise<QueryValueResult<TValue>>; encode(value: TValue, context: QueryEncodeContext): readonly string[];}Three things follow from those signatures:
- Input is an array. A key can appear more than once, so a codec always receives every value stored under its key and decides what that means.
- Output is an array. Encoding one value may produce zero entries (an absent key), one, or many (a list).
- Decoding returns a result, not a value. Failure is data.
The context carries the key being decoded and a path, which list items extend with their index
so an issue can point at tags[2].
What a codec must never do
Section titled “What a codec must never do”A codec is pure. It must not:
- navigate, or decide how a change should be recorded,
- read a request, a
Window, or any runtime global, - create reactive state,
- mutate anything shared.
This is enforced rather than requested: @queryweave/core compiles with types: [] and a
repository check rejects browser globals and Node built-ins anywhere in the package.
Writing one
Section titled “Writing one”import { createQueryIssue, failValue, okValue, param, type QueryCodec } from "@queryweave/core";
const isoDateCodec: QueryCodec<Date> = { decode: (input, context) => { const raw = input[0] ?? ""; const value = new Date(raw); if (Number.isNaN(value.getTime())) { return failValue([ createQueryIssue({ key: context.key, code: "invalid", input, message: `"${context.key}" must be an ISO date.`, path: context.path, }), ]); } return okValue(value); }, encode: (value) => [value.toISOString().slice(0, 10)],};
const createdAfter = param.custom(isoDateCodec).optional();okValue and failValue build the result; createQueryIssue builds an issue and freezes it,
dropping optional members you did not supply.
Consuming several values
Section titled “Consuming several values”By default param.custom treats a codec as single-valued, which means the parameter reports
unexpected_multiple_values and passes only the first. Opt in explicitly:
import { createQueryIssue, defineQueryModel, failValue, okValue, param, type QueryCodec,} from "@queryweave/core";
const priceBoundsCodec: QueryCodec<readonly number[]> = { decode: (input, context) => { const values = input.map(Number); return values.length === 2 && values.every(Number.isFinite) ? okValue(values) : failValue([ createQueryIssue({ key: context.key, code: "invalid", input, message: "Expected two prices.", }), ]); }, encode: (values) => values.map(String),};
const products = defineQueryModel({ price: param.custom(priceBoundsCodec, { consumesMultipleValues: true }).optional(),});
products.decode("?price=50&price=200"); // both raw values reach priceBoundsCodecproducts.encode({ price: [50, 200] }); // [["price", "50"], ["price", "200"]]Reporting a kind
Section titled “Reporting a kind”kind is metadata used for introspection and diagnostics. It defaults to "custom":
const createdAfter = param.custom(isoDateCodec, { kind: "text" }).optional();const products = defineQueryModel({ createdAfter });products.params.createdAfter.kind; // "text"Round-tripping
Section titled “Round-tripping”The rule a codec has to satisfy is that encoding a decoded value produces input that decodes to the same value. If that does not hold, canonical URLs are not canonical and the runtime can write a query it cannot read back.
const decoded = isoDateCodec.decode(["2026-03-02"], { key: "createdAfter", path: ["createdAfter"],});if (decoded.ok) { isoDateCodec.encode(decoded.value, { key: "createdAfter", path: ["createdAfter"] }); // ["2026-03-02"]}The repository asserts this property for every built-in family in tests/core/round-trip.test.ts.
Asynchronous decoding
Section titled “Asynchronous decoding”decodeAsync is optional and exists for one reason: validation vendors whose schemas are
asynchronous. Synchronous decoding must always remain available, so a codec cannot be
asynchronous-only.
When a value needs a promise and you call the synchronous decode, QueryWeave reports
async_required and recovers. It does not block the thread and it does not quietly return a wrong
value. decodeAsync on a list reaches its items’ decodeAsync, and a custom codec’s decodeAsync
is what the model’s asynchronous path calls.
A codec that throws is not a crash either: the exception becomes an invalid issue with the
error’s message, and the parameter recovers as for any other failure. A failure that reports no
issue at all is given one, so nothing recovers silently.
Query string handling
Section titled “Query string handling”Codecs never see percent-encoding. Parsing and formatting happen once, in the model’s input layer, through four exported helpers:
import { formatQueryString, normalizeQueryEntries, parseQueryString, selectQueryValues,} from "@queryweave/core";
parseQueryString("?tag=a&tag=b"); // [["tag", "a"], ["tag", "b"]]normalizeQueryEntries({ tag: ["a", "b"] }); // the same entriesselectQueryValues(entries, "tag"); // ["a", "b"]formatQueryString(entries); // "tag=a&tag=b"parseQueryString ignores one leading ?, skips empty segments, and treats a key without = as
an empty value. + always decodes to a space, and each run of percent sequences is decoded on its
own, so ?q=50%+off reads 50% off and only a malformed sequence, or a run that is not valid
UTF-8, stays verbatim rather than throwing. formatQueryString is
hand-written and stays byte-identical to URLSearchParams.prototype.toString, including + for
spaces, percent-encoding for !, ', (, ), and ~, and U+FFFD for a lone surrogate.
Decoding and encoding shows what the model does with these results.