Parameters
A QueryParam is one named value: its type, its presence, its default, and the constraints that
decide whether external input is acceptable. Parameters are created through the param factory and
narrowed with a small builder.
The families
Section titled “The families”@queryweave/core implements seven, and no more:
import { param } from "@queryweave/core";
param.text({ allowEmpty, maxLength, minLength, trim });param.integer({ max, min });param.number({ max, min });param.boolean({ falsy, truthy });param.choice(["name", "created_at"]);param.list(param.text(), { maxItems, minItems });param.custom(codec, { consumesMultipleValues, kind });Everything else — dates, JSON, objects, tuples — is expressed today as a param.custom codec.
Dedicated families for those representations are a
deferred decision, not a hidden feature.
const products = defineQueryModel({ search: param.text({ trim: true, minLength: 2, maxLength: 64 }).optional(),});
const result = products.decode("?search=%20wireless%20headphones%20");if (result.ok) result.value.search; // "wireless headphones"Trimming happens before length checks, so " ab " with trim and minLength: 2 is valid, and
before the empty-value rule, so a whitespace-only value reports empty. A length violation reports
out_of_range.
integer and number
Section titled “integer and number”const products = defineQueryModel({ page: param.integer({ min: 1 }).default(1), rating: param.number({ min: 0, max: 5 }).optional(),});
const valid = products.decode("?page=2&rating=4.5");if (valid.ok) valid.value; // { page: 2, rating: 4.5 }
const recovered = products.decode("?page=2.5&rating=9");recovered.issues.map((issue) => issue.code); // ["invalid", "out_of_range"]integer accepts an optional sign and digits only, and rejects anything outside the safe integer
range with out_of_range. number accepts plain decimal notation with an optional exponent —
4.5, .5, -2, 1e3 — and reports invalid for everything else, including 0x10, NaN, and
Infinity, which JavaScript’s own Number() would accept. Both decode -0 as 0.
boolean
Section titled “boolean”const products = defineQueryModel({ inStock: param.boolean().default(false),});
const result = products.decode("?inStock=yes");if (result.ok) result.value.inStock; // trueproducts.encode({ inStock: true }); // [["inStock", "true"]]Comparison is case-insensitive, for the built-in spellings and for custom ones. Encoding writes the
first entry of the matching list as you spelled it, so truthy: ["Yes"] accepts yes and produces
?flag=Yes. An empty list, or a spelling listed as both truthy and falsy, throws at construction.
choice
Section titled “choice”const products = defineQueryModel({ sort: param.choice(["relevance", "price_asc", "price_desc"]).default("relevance"),});
products.encode({ sort: "price_desc" }); // [["sort", "price_desc"]]The value type narrows to the union of the literals. An unrecognized value reports
unknown_choice, which is distinct from invalid so a caller can tell “not one of these” from
“not the right shape”.
const products = defineQueryModel({ category: param.list(param.choice(["books", "games", "music"])).default([]),});
const result = products.decode("?category=books&category=music");if (result.ok) result.value.category; // ["books", "music"]
products.encode({ category: ["books", "music"] }); // repeated category entriesA list consumes every value stored under its key, so ?tag=a&tag=b decodes to ["a", "b"]. Each
item is decoded by the item parameter’s codec, and item issues carry the index in their path.
An empty list has one spelling: a single empty value. products.encode({ category: [] }) writes
category= when [] is not the declared default, and ?category= decodes to [] with no issue.
That is what lets a required list hold nothing, an optional list tell “absent” from “cleared”, and
a list whose default is non-empty be emptied. An empty entry beside real values — ?tag=a&tag= —
is dropped and reported as empty.
Two shapes have no representation in a query string and throw at construction: a list of lists,
and .nullable() on a list.
custom
Section titled “custom”import { createQueryIssue, failValue, okValue, param } from "@queryweave/core";
const isoDate = param.custom<Date>({ decode: (input, context) => { const raw = input[0] ?? ""; const value = new Date(raw); return Number.isNaN(value.getTime()) ? failValue([ createQueryIssue({ key: context.key, code: "invalid", message: "Expected an ISO date." }), ]) : okValue(value); }, encode: (value) => [value.toISOString().slice(0, 10)],});param.custom is the extension point. See Codecs for the full contract.
Presence
Section titled “Presence”Presence is what a parameter does when its key is absent from the query. There are exactly three values, and they are visible in the type.
| Presence | Created by | Absent key produces | Appears in defaults() |
|---|---|---|---|
required |
the default | a missing issue |
no |
optional |
.optional() |
undefined |
yes, as undefined |
default |
.default(v) |
the declared value | yes |
const products = defineQueryModel({ category: param.text(), // required: a missing key fails the decode search: param.text().optional(), // string | undefined page: param.integer().default(1), // number, always present});A required parameter is the only one that can make a whole decode fail. That is deliberate: if you declared that a key must exist, producing a complete state without it would be a lie.
The builder
Section titled “The builder”Every constructor returns a builder that can be narrowed further:
const search = param .text({ trim: true }) .describe("Free-text product search") .nullable() .optional();
const products = defineQueryModel({ search });.describe(text)attaches documentation. It has no effect on decoding..nullable()makes a present-but-empty value decode tonullinstead of reportingempty, and encodesnullback to an empty value. This is how you distinguish “set to nothing” from “not set”..optional()sets presence tooptionaland widens the type withundefined..refine(refinement)adds validation, and may transform the value’s type when the refinement provides the inverse..default(value)sets presence todefaultand closes the chain — it returns aQueryParam, not a builder. The value must be one the parameter’s own codec accepts:param.integer({ min: 1 }).default(0)throws, and so doesparam.text().default("")withoutallowEmpty. The stored default is a frozen copy, shared safely by every decode.
Refinement
Section titled “Refinement”A refinement validates an already decoded value and may transform it:
const slug = param.text().refine({ name: "lowercase", refine: (value) => value === value.toLowerCase() ? { ok: true, value } : { ok: false, issues: [{ message: "Must be lowercase." }] },});Refinements form a pipeline: each one receives the previous one’s output. A failure becomes a
validation_failed issue and the parameter recovers according to its presence. A refinement never
receives null or undefined; those are settled before it runs.
A refinement that changes the value’s type is a transform, and must provide encode, the inverse
QueryWeave applies when the value is written back:
const count = param.text().refine({ refine: (value) => /^\d+$/.test(value) ? { ok: true, value: Number(value) } : { ok: false, issues: [{ message: "Digits only." }] }, encode: (value) => String(value),});
// count is number; `encode` runs last-to-first through a pipeline when writingWithout encode, a type-changing refinement does not type-check against .refine().
A refinement may return a promise. Synchronous decode cannot wait for it, so it reports
async_required — it does not block, and it does not silently succeed. Declare it with
async: true so the synchronous path does not start it. This is how Standard Schema
validators with asynchronous schemas participate, and a runtime turns it into a
pending snapshot that settles on its own.
Edge cases
Section titled “Edge cases”- Multiple values for a single-value parameter: reports
unexpected_multiple_valuesand uses the first. It does not fail, because dropping the extras is recoverable and predictable. - Present but empty (
?q=): reportsemptyand recovers, unless the parameter is.nullable()(decodes tonull) ortext({ allowEmpty: true })(decodes to""). undefinedat encode time: emits nothing, so the key is absent from the URL.nullat encode time: emits an empty value if the parameter is nullable, nothing otherwise.- Inverted bounds —
minabovemax,minLengthabovemaxLength,minItemsabovemaxItems— throw at construction. - A codec or refinement that throws becomes an
invalidorvalidation_failedissue carrying the error’s message; decoding never throws.
How it is tested
Section titled “How it is tested”tests/core/semantics.test.ts covers presence, recovery, and the issue codes each family emits;
tests/core/parameters.test.ts covers grammar, defaults, empty lists, transforms, and exceptions;
tests/core/round-trip.test.ts asserts that decoding an encoded value returns the original for
every family.
Codecs describes the contract a parameter’s translation must satisfy.