Skip to content
Runtime
Framework

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;
refine(
value: TInput,
context: QueryRefineContext,
): QueryRefinementResult<TOutput> | Promise<QueryRefinementResult<TOutput>>;
}

@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:

const model = defineQueryModel({
search: param.text().refine(fromStandardSchema(z.string().transform((value) => value.length))),
});
// search is now number
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 validation_failed with a message naming decodeAsync and recovers by presence — it does not block and does not silently pass.

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

tests/standard-schema/compatibility.test.ts runs one contract across Zod, Valibot, and ArkType, asserting normalized issues and inferred output types — never a vendor’s own error shape. The standard-schema consumer fixture repeats it against the packed archive.