Skip to content
QueryWeave

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

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.date({ max, min });
param.datetime({ max, min });
param.list(param.text(), { maxItems, minItems });
param.custom(codec, { consumesMultipleValues, kind });

Tuples, objects, and JSON go through param.custom for now. Their URL spellings are already decided in ADR 0011, and the families are planned.

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

A calendar date, such as a day to filter from. It stays the YYYY-MM-DD string it is written as:

const trips = defineQueryModel({
from: param.date({ min: "2026-01-01" }).optional(),
to: param.date().optional(),
});
const result = trips.decode("?from=2026-09-01&to=2026-09-30");
if (result.ok) result.value; // { from: "2026-09-01", to: "2026-09-30" }
trips.decode("?from=2025-12-31").issues.map((issue) => issue.code); // ["out_of_range"]

A calendar date has no time and no zone, so it is not turned into a Date. A Date is a moment, and midnight UTC on the 24th is still the 23rd in New York, so a reader there would see the wrong day. The string is what <input type="date"> reads and writes, what SQL date columns accept, and it sorts correctly as text, which is how min and max compare.

Only the exact form is accepted: four-digit year from 0001 to 9999, two-digit month and day, and a day the month really has, leap years included. 2026-9-4, 20260924, 2026-02-30, and a date with a time are invalid. When you want a Date or a Temporal.PlainDate, convert with a transform and its encode, so the time-zone choice is visible where you make it.

A moment in time, such as when an event starts. It decodes to a Date and is always written in UTC:

const events = defineQueryModel({
since: param.datetime().optional(),
});
const result = events.decode("?since=2026-09-24T12:00:00%2B02:00");
if (result.ok) result.value.since?.toISOString(); // "2026-09-24T10:00:00.000Z"
events.encode({ since: new Date("2026-09-24T10:00:00Z") }); // [["since", "2026-09-24T10:00:00Z"]]

The URL shows since=2026-09-24T10%3A00%3A00Z, because every : is percent-encoded. Seconds are always written, milliseconds only when they are not zero.

Decoding accepts a Z or a numeric offset such as +02:00 and converts it to UTC. A + typed by hand in a URL arrives as a space, so a space where the offset sign belongs is read as +. A time with no offset is invalid, because the server and the browser could each read it in a different time zone. min and max are Date values.

A Date can be changed through setTime even when frozen, so treat a decoded value as read-only and assign a new Date to change it. A transaction’s draft gets its own copies, and every caller that receives a default gets its own copy of it.

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 orderId = param.custom<bigint>({
decode: (input, context) => {
const raw = input[0] ?? "";
return /^\d+$/u.test(raw)
? okValue(BigInt(raw))
: failValue([
createQueryIssue({
key: context.key,
code: "invalid",
message: "Expected a whole number.",
}),
]);
},
encode: (value) => [String(value)],
});

param.integer stops at the safe integer range; this codec keeps IDs of any length exact.

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 bounds — min above max, minLength above maxLength, minItems above maxItems — throw at construction. So does a date bound that is not a real YYYY-MM-DD date, and a datetime bound that is an invalid Date.
  • An invalid Date at encode time — new Date("nope"), or a year past 9999 — throws, which rejects the transition. Writing it would produce a URL nothing could read back.
  • A codec or refinement that throws becomes an invalid or validation_failed issue carrying the error’s message; decoding never throws.

Codecs describes the contract a parameter’s translation must satisfy.