Skip to content
QueryWeave

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.

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.

  1. Raw values — ["2"]
  2. Codec
  3. Typed value — 2
  4. Refinement
  5. Validated value — 2

Raw values → Codec. Codec → Typed value. Typed value → Refinement. Refinement → Validated value.

A refinement runs after decoding, on a value that already has the right type.

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";
Terminal window
pnpm add @queryweave/core @queryweave/standard-schema

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

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 presence

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

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 values

Model refinements run only after every parameter decoded, and their issues are reported under the key $ (modelIssueKey).

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.

const result = await products.decodeAsync("?search=wireless");
result.ok; // true after the asynchronous schema resolves
result.issues; // any rejection is normalized to QueryWeave issues

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

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.

Any library implementing Standard Schema v1. The repository verifies three of them against one shared contract: see Zod, Valibot, ArkType.

  • Refinements are a pipeline. Each receives the previous one’s output, in declaration order.
  • A refinement never sees null or undefined. 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_required and its asynchronous decode reads validation_failed.