Query models
A QueryModel describes how external query values become typed application state, and how that
state is encoded back into a canonical query. It is a plain value with no environment access, so
the same model works on a server, in a browser, and in a test.
Defining a model
Section titled “Defining a model”import { defineQueryModel, param } from "@queryweave/core";
const products = defineQueryModel({ search: param.text().optional(), page: param.integer({ min: 1 }).default(1), sort: param.choice(["name", "created_at", "price"]).default("created_at"), tags: param.list(param.text()).default([]),});The object keys are the external query keys. There is no separate mapping step and no renaming, so what you read in the model is what appears in the URL.
What a model exposes
Section titled “What a model exposes”products.name; // string | undefined — the optional name given in optionsproducts.params; // the parameter definitions, for introspectionproducts.keys(); // readonly ["search", "page", "sort", "tags"]products.defaults(); // { search: undefined, page: 1, sort: "created_at", tags: [] }products.decode(input); // DecodeResult<Values>products.decodeAsync(input); // Promise<DecodeResult<Values>>products.encode(values); // QueryOutput — readonly [key, value] pairsproducts.normalize(input); // QueryOutput — decode, recover, then encodedefaults() materializes only the parameters that have one. A required parameter has no default
and is therefore absent from that object, which is visible in the type as well as at runtime.
Inferred types
Section titled “Inferred types”The value type follows from the definitions; nothing is written twice.
import type { QueryModelValues } from "@queryweave/core";
type ProductValues = QueryModelValues<typeof products.params>;// {// search: string | undefined;// page: number;// sort: "name" | "created_at" | "price";// tags: readonly string[];// }param.choice narrows to a union of literals, param.list produces a readonly array, and
.optional() widens the value with undefined while changing the parameter’s presence.
Naming a model
Section titled “Naming a model”const products = defineQueryModel( { page: param.integer({ min: 1 }).default(1), sort: param.choice(["relevance", "price"]).default("relevance"), }, { name: "product-catalog" },);
products.name; // "product-catalog"products.keys(); // ["page", "sort"] — the name does not change the model's keysThe name is metadata for introspection. It does not appear in the URL and does not affect decoding.
Model-level refinement
Section titled “Model-level refinement”Individual parameters can be refined on their own, but some rules involve more than one key — for example, a maximum price that must not be lower than the minimum. Those belong to the model.
const priceFilters = defineQueryModel( { minPrice: param.number({ min: 0 }), maxPrice: param.number({ min: 0 }), }, { refine: [ { name: "ordered-price-range", refine: (values) => values.minPrice <= values.maxPrice ? { ok: true, value: values } : { ok: false, issues: [{ message: "Minimum price must not exceed maximum price." }] }, }, ], },);
const result = priceFilters.decode("?minPrice=200&maxPrice=100");result.ok; // falseresult.issues[0]?.key; // "$" — the issue belongs to the whole modelA model refinement runs only after every parameter decoded successfully — there is no point
checking a relationship between values that do not exist yet. Its failures are reported under the
key $, exported as modelIssueKey, because they belong to the whole rather than to one
parameter.
Keys the model does not manage
Section titled “Keys the model does not manage”A model describes the keys it knows about and ignores the rest. encode emits only managed keys;
it does not preserve anything else, because a pure function over typed state has no way to know
what else was in the URL.
Preservation happens one level up, in the runtime, which reads the current query and
re-attaches unmanaged entries after the managed ones. This is what lets QueryWeave own ?page= and
?sort= while an analytics parameter continues to ride along untouched.
Edge cases
Section titled “Edge cases”- An empty key throws.
defineQueryModel({ "": param.text() })raises aTypeErrorat definition time rather than producing a model that can never round-trip. So does$, which is reserved for model-level issues. - A numeric key sorts first. JavaScript enumerates a key such as
"2"before every other key whatever the definition order, so canonical output follows that rule too. - Two models on one URL work when their keys are disjoint; each runtime preserves the other’s keys as unmanaged entries. Two models that manage the same key will each re-encode it with their own codec and defaults, so share one model instead.
- Duplicate external values are handed to the parameter, which decides. A single-value
parameter reports
unexpected_multiple_valuesand uses the first;param.listconsumes them all. - Unknown keys in the input are ignored by
decode. They are not an error, because a model is a description of what you care about, not an assertion about the whole URL.
How it is tested
Section titled “How it is tested”The core Vitest project covers definition, inference, and composition; tests/types asserts both
what the inferred types accept and what they reject, so a widened type fails the build.
Parameters covers the families a model is built from.