Serialization & the Wire Format
Explicit, predictable URL serialization owned by the library.
Standard Schema is validate-only, so paramour owns serialization: every codec defines exactly how values appear in the URL, per a numbered wire-format spec.
URLs are public API
People copy URLs into Slack, bookmark them, and put them in support tickets. Other services parse them. Your analytics segment on them. A URL outlives the deploy that produced it — which makes its format a public contract, whether you treat it as one or not.
Most routing setups leave that contract implicit: whatever
String(value) happens to produce, whatever URLSearchParams happens to
accept. Paramour's position is that every serialization decision should be
explicit, documented, and stable. If you can predict the exact URL from the
value — and the exact value from the URL — the format is doing its job.
What values look like on the wire
Every example below is real output from the shipped library — the "On the wire" column is computed by the docs build, not hand-written:
| Codec | In memory | On the wire |
|---|---|---|
p.boolean() | true | ?debug=true |
p.isoDate() | Date("2026-07-18") | ?since=2026-07-18 |
p.timestamp() | Date("…T12:30:00Z") | ?at=2026-07-18T12%3A30%3A00.000Z |
p.index() | 0 | ?page=1 |
p.json(schema) | { a: 1 } | ?f=%7B%22a%22%3A1%7D |
p.array() | ["sale", "new"] | ?tags=sale&tags=new |
p.csv() | ["sale", "new"] | ?tags=sale%2Cnew |
p.string() | "wool socks" | ?q=wool%20socks |
Two layers are visible here. The value layer is each codec's grammar —
true/false for booleans, YYYY-MM-DD for dates, one repeated key per
array element. The byte layer is percent-encoding, which paramour also
pins down deliberately: spaces are %20 (never +), and encoding is
encodeURIComponent-strict, so a decoded URL means the same thing everywhere.
Round-tripping
The route methods and href do all of this for you, but the machinery is
public — useful in route handlers, middleware, or anywhere you hold a search
config without a request:
import { , , } from "paramour";
const = {
: .().(1),
: .().(),
: .(),
};
const = (, {
: 1,
: new ("2026-07-18T00:00:00Z"),
: ["sale", "new"],
});
// => "?tags=sale%2Cnew&since=2026-07-18"
const decoded = (, new ());(searchToString composes two smaller pieces — encodeSearch to ordered
key/value pairs, buildSearchString to bytes — if you need to intervene
between them.)
Notice what happened to page. It was passed as 1, equal to its
.default(1) — so it was elided. Serializing a state and decoding the
result always lands on the same state, and every state has exactly one
canonical URL: no ?page=1 and ? aliases of each other, no cache keys that
differ by a default. (Only value-form defaults elide; factory defaults never
do — see modifiers.)
Absent optionals are omitted entirely, and on decode, keys paramour doesn't
own are ignored: ?utm_source=… can never fail your parse.
When decoding fails
Strictness needs good failure ergonomics. A failed decode aggregates every
bad key into one structured error — issues[], one entry per key, not a
throw on the first problem:
import { , , } from "paramour";
const = ("/product/[id]", {
: { : .() },
: { : .().(1), : .() },
});
const = (
,
new ("?page=abc&tags=sale,new"),
);
if (. === "error") {
..issues; // => [{ key: "page", message: '"abc" is not an integer' }]
}The same issues[] shape appears everywhere a decode can fail — route
methods, hooks, the standalone helpers — so error rendering is written once.
Per-key recovery is opt-in via .catch().
The normative spec
Everything on this page is behavior; the letter of the law is the
wire-format spec — every rule numbered and
stable-anchored (#s3, #cv4, …), each pinned by the library's
conformance test suite and illustrated with examples the docs build
computes against the shipped package. When you need to know exactly what
lands in the URL — absence vs. the empty string, duplicate keys, catch-all
segments, csv grammar — the spec is the page to cite.