Standard Schema
QueryWeave does not bundle a validation library, and it never will. Validators join through Standard Schema, a specification several libraries implement, so the choice stays yours and stays replaceable.
Where validation fits
Section titled “Where validation fits”Codecs already decide shape. param.integer({ min: 1 }) rejects abc and rejects 0 without any
validator at all. Validation is the layer above that: rules a codec should not know about, such as
“this slug must exist” or “this range must be ordered”.
The path a query takes through the model, stage by stage.
- Raw values — ["2"]
- Codec
- Typed value — 2
- Refinement
- Validated value — 2
Raw values → Codec. Codec → Typed value. Typed value → Refinement. Refinement → Validated value.
The contract
Section titled “The contract”QueryWeave’s own hook is QueryRefinement, which is deliberately tiny:
interface QueryRefinement<TInput, TOutput = TInput> { readonly name?: string | undefined; readonly async?: boolean | undefined; refine( value: TInput, context: QueryRefineContext, ): QueryRefinementResult<TOutput> | Promise<QueryRefinementResult<TOutput>>; encode?(value: TOutput): TInput;}@queryweave/standard-schema adapts any Standard Schema validator into that shape. It is the only
vendor-facing function in the entire project:
import { fromStandardSchema } from "@queryweave/standard-schema";Install
Section titled “Install”pnpm add @queryweave/core @queryweave/standard-schemaThe package depends on @standard-schema/spec — a types-only package — and on nothing else. No
validator is a dependency of any published QueryWeave package, and a repository check fails the
build if one ever becomes one.
Validating one parameter
Section titled “Validating one parameter”import { defineQueryModel, param } from "@queryweave/core";import { fromStandardSchema } from "@queryweave/standard-schema";import { z } from "zod";
const products = defineQueryModel({ search: param .text() .refine(fromStandardSchema(z.string().min(2))) .optional(),});
const accepted = products.decode("?search=vue");if (accepted.ok) accepted.value.search; // "vue"
const rejected = products.decode("?search=v");rejected.issues[0]?.code; // "validation_failed"rejected.ok && rejected.value.search; // undefined — recovered by presenceThe failure became a QueryWeave issue. Zod’s error type never reaches your code, which is what lets you change validators later without touching anything that reads issues.
Validating the whole model
Section titled “Validating the whole model”Rules that involve more than one key belong to the model:
const priceFilters = defineQueryModel( { minPrice: param.number({ min: 0 }), maxPrice: param.number({ min: 0 }), }, { refine: [fromStandardSchema(orderedPriceRangeSchema)], },);
const result = priceFilters.decode("?minPrice=200&maxPrice=100");result.issues[0]?.key; // "$" — this rule involves both valuesModel refinements run only after every parameter decoded, and their issues are reported under the
key $ (modelIssueKey).
Transforming values
Section titled “Transforming values”A Standard Schema that transforms changes the parameter’s value type, and QueryWeave follows it — provided you say how to write the value back:
const model = defineQueryModel({ count: param.text().refine( fromStandardSchema( z .string() .regex(/^\d+$/) .transform((value) => Number(value)), { encode: (value) => String(value) }, ), ),});
// count is now number, and encodes through `encode`refine() accepts a validating or narrowing schema as it is. A schema whose output type differs
from its input does not type-check without encode: a value that cannot be written back to a URL
is not URL state. That rules out lossy transforms such as value.length — which is the point.
Asynchronous schemas
Section titled “Asynchronous schemas”const result = await products.decodeAsync("?search=wireless");
result.ok; // true after the asynchronous schema resolvesresult.issues; // any rejection is normalized to QueryWeave issuesA refinement may return a promise. Synchronous decode cannot wait, so it reports async_required
and recovers by presence — it does not block and does not silently pass. Declare the schema as
asynchronous so the synchronous path does not start it:
const slug = fromStandardSchema(slugIsFreeSchema, { async: true });A runtime treats such a snapshot as pending, decodes asynchronously, and notifies
once it settles; runtime.settled() awaits that. A vendor that throws — from a transform, say —
becomes a validation_failed issue, not an exception.
Naming a refinement
Section titled “Naming a refinement”const productSearch = fromStandardSchema(schema, { name: "product-search" });
const products = defineQueryModel({ search: param.text().refine(productSearch).optional(),});The name defaults to the validator’s own vendor string and is used for diagnostics only.
Which validators work
Section titled “Which validators work”Any library implementing Standard Schema v1. The repository verifies three of them against one shared contract: see Zod, Valibot, ArkType.
Edge cases
Section titled “Edge cases”- Refinements are a pipeline. Each receives the previous one’s output, in declaration order.
- A refinement never sees
nullorundefined. Absence and explicit null are settled before it runs, and they stay in the value type. - A failed refinement recovers by presence, exactly like a codec failure: a default, or
undefined, or a failed result for a required parameter. - Issue paths are preserved. A validator’s path is appended to the parameter’s path, so a nested failure still points at the right place.
- A validator error is never thrown. It becomes an issue. Zod reports a throwing synchronous
transform as a rejected promise, so its synchronous decode reads
async_requiredand its asynchronous decode readsvalidation_failed.