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 bigintCodec: QueryCodec<bigint> = { decode: (input, context) => { const raw = input[0] ?? ""; if (!/^\d+$/u.test(raw)) { return failValue([ createQueryIssue({ key: context.key, code: "invalid", input, message: `"${context.key}" must be a whole number.`, path: context.path, }), ]); } return okValue(BigInt(raw)); }, encode: (value) => [String(value)],};
const after = param.custom(bigintCodec).optional();This keeps order IDs such as ?after=12345678901234567890 exact, past the safe integer range where
param.integer stops.
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 after = param.custom(bigintCodec, { kind: "integer" }).optional();const orders = defineQueryModel({ after });orders.params.after.kind; // "integer"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 = bigintCodec.decode(["12345678901234567890"], { key: "after", path: ["after"] });if (decoded.ok) { bigintCodec.encode(decoded.value, { key: "after", path: ["after"] }); // ["12345678901234567890"]}A codec that accepts more than it writes breaks this. One that reads a full timestamp but writes
only the day, for example, turns 2026-03-02T23:00:00-05:00 into a different day on the next
write. Every built-in family satisfies the rule.
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.