Defaults and absence
The rule
Section titled “The rule”A parameter declared with .default(v) treats an absent key and the value v as the same state.
That equivalence runs in both directions:
- Decoding: an absent key produces
v. - Encoding: a value equal to
vproduces no key.
const products = defineQueryModel({ page: param.integer({ min: 1 }).default(1) });
const absent = products.decode("");const explicit = products.decode("?page=1");
if (absent.ok) absent.value.page; // 1if (explicit.ok) explicit.value.page; // 1
products.encode({ page: 1 }); // []products.encode({ page: 2 }); // [["page", "2"]]Why this matters
Section titled “Why this matters”Without the encoding half, ?page=1 and an empty query would decode identically but serialize
differently. That means:
- two links to the same view are not equal strings, so caches and analytics see two views,
- a “copy link” button produces noise proportional to the number of parameters,
- and comparing “did the query change?” requires a semantic comparison instead of a string one.
Omission is not a size optimization. It is what makes the canonical form canonical.
- history.pushState
- history.replaceState
- popstate
The adapter writes through the History API and re-reads on popstate.
Type a query, press Enter.
Typed state
{
"page": 1,
"sort": "created_at",
"status": "all"
}Canonical URL
Valid/productsThree kinds of absence
Section titled “Three kinds of absence”QueryWeave distinguishes states that a Record<string, string> would flatten together.
| State | Query | Decoded value | Encoded again |
|---|---|---|---|
| Not set, has a default | (absent) | the default | (absent) |
| Not set, optional | (absent) | undefined |
(absent) |
| Set to nothing, nullable | ?q= |
null |
?q= |
| Set to nothing, plain | ?q= |
recovered | (absent) |
| Required and absent | (absent) | decode fails | — |
The third row is the one that is easy to miss. If your application needs “the user explicitly
cleared this field” to be different from “the user never touched it”, .nullable() is what
expresses it:
const filters = defineQueryModel({ category: param.text().nullable().optional(),});
const untouched = filters.decode("");const cleared = filters.decode("?category=");
if (untouched.ok) untouched.value.category; // undefined — never setif (cleared.ok) cleared.value.category; // null — explicitly clearedClearing a value
Section titled “Clearing a value”Because undefined encodes to nothing, assigning it is how you remove a key:
// current URL: ?search=vue&page=3await runtime.transaction((draft) => { draft.search = undefined; draft.page = 1;});// written URL: /productsThe runtime also has an explicit operation, which is clearer when you are removing several keys and do not care about their current values:
// current URL: ?search=vue&page=3await runtime.remove(["search", "page"]);// written URL: /products
// from the same initial URL, reset every managed key to its defaultawait runtime.reset();// written URL: /productsremove omits the keys from the written output. reset sets them to their defaults, which — for a
parameter that has one — produces the same URL. The difference appears for optional parameters:
resetting one sets it to undefined, which is also its default.
Required parameters have no default
Section titled “Required parameters have no default”A required parameter cannot be defaulted, by construction: .default() changes presence to
default. This is why defaults() returns only the parameters that have one, and why its type
excludes required keys.
const model = defineQueryModel({ id: param.text(), page: param.integer().default(1),});
model.defaults(); // { page: 1 } — `id` is not present, in the type or at runtimeEdge cases
Section titled “Edge cases”- A default that cannot be encoded to itself breaks omission. If a custom codec encodes
1as"01", then decoding"01"must produce1again, or the comparison inencodewill not match..default()runs that round trip when you declare it and throws when it fails. - List defaults are compared by their encoded form, so
.default([])is dropped correctly without any deep-equality helper. An empty list that is not the default is written as one empty value,tags=, which reads back as[]. - A default is validated against the parameter’s own constraints when you declare it.
param.integer({ min: 5 }).default(1)throws, because it would produce a state that decoding could never yield. Refinements are not run on defaults. - Defaults are frozen copies. Every decode of an absent key hands out the same frozen array or object, so a transaction that tries to mutate it in place throws instead of changing the next request’s state. Replace nested values; do not mutate them.
Issues and recovery covers what happens when the input is wrong.