Skip to content
Runtime
Framework

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.

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

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.

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.

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 priceBoundsCodec
products.encode({ price: [50, 200] }); // [["price", "50"], ["price", "200"]]

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"

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.

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.

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 entries
selectQueryValues(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.