Skip to content
Runtime
Framework

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.

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.

products.name; // string | undefined — the optional name given in options
products.params; // the parameter definitions, for introspection
products.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] pairs
products.normalize(input); // QueryOutput — decode, recover, then encode

defaults() 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.

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.

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 keys

The name is metadata for introspection. It does not appear in the URL and does not affect decoding.

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; // false
result.issues[0]?.key; // "$" — the issue belongs to the whole model

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

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.

  • An empty key throws. defineQueryModel({ "": param.text() }) raises a TypeError at 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_values and uses the first; param.list consumes 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.

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.