Skip to content
Runtime
Framework

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.

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

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.

const products = defineQueryModel({
inStock: param.boolean().default(false),
});
const result = products.decode("?inStock=yes");
if (result.ok) result.value.inStock; // true
products.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.

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 entries

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

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

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 to null instead of reporting empty, and encodes null back to an empty value. This is how you distinguish “set to nothing” from “not set”.
  • .optional() sets presence to optional and widens the type with undefined.
  • .refine(refinement) adds validation, and may transform the value’s type when the refinement provides the inverse.
  • .default(value) sets presence to default and closes the chain — it returns a QueryParam, not a builder. The value must be one the parameter’s own codec accepts: param.integer({ min: 1 }).default(0) throws, and so does param.text().default("") without allowEmpty. The stored default is a frozen copy, shared safely by every decode.

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 writing

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

  • Multiple values for a single-value parameter: reports unexpected_multiple_values and uses the first. It does not fail, because dropping the extras is recoverable and predictable.
  • Present but empty (?q=): reports empty and recovers, unless the parameter is .nullable() (decodes to null) or text({ allowEmpty: true }) (decodes to "").
  • undefined at encode time: emits nothing, so the key is absent from the URL.
  • null at encode time: emits an empty value if the parameter is nullable, nothing otherwise.
  • Inverted boundsmin above max, minLength above maxLength, minItems above maxItems — throw at construction.
  • A codec or refinement that throws becomes an invalid or validation_failed issue carrying the error’s message; decoding never throws.

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.