Errors
The ParamourError hierarchy, brand-based instanceof, and the Issue shape shared by every failed decode.
Every error paramour throws is a ParamourError. Foreign throws — a
Standard Schema validator that throws instead of returning issues, a
.default() factory that fails, a props promise that rejects — are caught
at chokepoints and rebranded into the hierarchy, with the original attached
as cause. So one instanceof ParamourError check in an error boundary
catches everything the library can produce.
ParamourError
├─ ParseError one wire value failed its codec grammar or schema
├─ SerializeError a value could not be serialized to the wire
├─ ParamsDecodeError aggregate params-decode failure — carries issues
├─ SearchDecodeError aggregate search-decode failure — carries issues
└─ SearchSourceError a source violated the wire-shape contract — carries keyinstanceof is hardened with cross-copy identity brands: each class brands
its prototype with a Symbol.for() symbol, which resolves in the
realm-global symbol registry — so a second physical copy of the module
(dual-package hazard, bundler duplication) mints the same symbols, and
instanceof recognizes instances across copies. A structurally identical
foreign class lacks the brands entirely and never passes. Each class checks
its own brand (a ParseError check never matches a plain ParamourError),
and the checks are typed as predicates, so TypeScript narrows through them.
ParamourError
The base class — and the type thrown directly for contract violations:
an invalid path literal at define time, a hand-built route missing a codec,
a config or source that isn't even an object, a rebranded foreign throw.
These are programming errors, so they deliberately stay loud — the safe*
APIs never soften them into the error arm.
ParseError
A single wire value failed its codec grammar or schema validation —
"abc" is not an integer, "2026-13-40" is not a date. This is the
element-level error, and it is exactly what .catch() recovers: inside the
high-level decoders every ParseError is either caught into the codec's
fallback or folded into the aggregate's issues[], so you normally meet it
as an issue message rather than a raw throw. It surfaces directly only on
low-level paths (custom codec internals, tooling using
paramour/internal).
SerializeError
A value could not be serialized to the wire: the wrong runtime type, a
value failing its codec or schema on serialize, a required param or search
key missing at encode time, a required catch-all given [], a segment
serializing to "", a custom serializer returning a non-string, or
unencodable text (lone surrogates). Thrown by the whole encode surface —
href, buildPath, encodeParams, encodeStaticParams, encodeSearch,
searchToString, buildSearchString, serializeValue — at link-build
time, in the component that introduced the bad value.
ParamsDecodeError
The aggregate failure for a whole params decode. Carries issues, one
entry per failed key — a decode never stops at the first problem:
import {
,
,
,
,
,
} from "paramour";
const = ("/product/[id]", {
: { : .() },
});
try {
(, { : "abc" });
} catch () {
if ( instanceof ) {
.issues; } else if ( instanceof ) {
// contract violation — a bug in the caller, not a bad URL
throw ;
}
}Thrown by decodeParams, route.parseParams, and the params half of
route.parse / route.parseContext; returned in the error arm by their
safe* twins.
SearchDecodeError
The aggregate failure for a whole search decode — same shape, same
issues property, so error rendering written for one surface works for
both. Thrown by decodeSearch, route.parseSearch, the search half of
route.parse / route.parseContext, and a failed rawSearch schema
(whose root-level issues appear under the key "<search>");
standardSearchSchema converts it to spec-shaped issues instead of
throwing.
The message is the pretty printout
Both aggregate errors also carry route — the owning route's path
pattern, or null when the decode ran outside a route (a bare
decodeSearch call) — and their message renders the whole failure,
one ✖ line per issue:
Failed to decode search params for /users/[id]:
✖ page: required search param is missing (expected integer)
✖ sort: "up" is not one of: asc, descThere is no separate prettify step on purpose: an unhandled decode error
reaches a Next error boundary or the dev overlay as error.message, so
the default message is the one tuned for that moment. (expected …) is
keyed on the issue's structured reason: it is appended exactly where the
message cannot name the expected shape itself — a "missing" key has no
value to describe, and a "validate" failure carries validator prose that
may name neither the value nor the grammar. Core's own "parse" grammar
messages already quote the value and name the grammar, and a "duplicate"
or "shape" message states a problem that isn't about the grammar at all,
so none of those take the suffix.
SearchSourceError
A search source violated its wire-shape contract: the source isn't an
object, or a declared key's value isn't a string / string[]. Carries
key — the offending source key, or null when the source itself is
malformed. This is distinct from a decode failure on purpose: a malformed
source is the caller's bug, so it stays loud everywhere — including through
safeDecodeSearch — with one exception: standardSearchSchema's
validate() receives genuinely untrusted input, so exactly these errors
soften to spec issues at that one boundary.
Which API throws what
| API | Failure |
|---|---|
decodeParams, route.parseParams | ParamsDecodeError |
decodeSearch, route.parseSearch | SearchDecodeError; SearchSourceError for a contract-violating source |
route.parse, route.parseContext | ParamsDecodeError or SearchDecodeError — params decode first |
href, buildPath, encodeParams, encodeStaticParams, encodeSearch, searchToString, buildSearchString | SerializeError |
defineAppRoute, definePagesRoute | ParamourError on an invalid path literal |
| any API given a config or source that breaks its documented shape (plain-JS callers) | base ParamourError |
The safe* and safeDecode* variants return the two aggregate decode
errors in their error arm instead of throwing; everything else in this
table still throws through them.
Related types
RouteDecodeError is the union of the two aggregate errors — the type
of SafeResult's error arm:
type RouteDecodeError = ParamsDecodeError | SearchDecodeError;Issue is one failed key in an aggregate error — the same shape on
both surfaces (and in the hooks and devtools), so issue rendering is
written once:
interface Issue {
readonly expected?: string;
readonly key: string;
readonly message: string;
readonly reason?: IssueReason;
readonly wire?: string;
}
type IssueReason = "duplicate" | "missing" | "parse" | "shape" | "validate";message is the human prose; expected, reason, and wire are its
structured halves for custom rendering. reason says what kind of
failure the issue records — "missing" (no wire value for a required
key), "duplicate" (multiple values for a single-value param),
"shape" (source shape mismatch: an array where a segment belongs, a
non-string element), "parse" (the codec's own wire grammar rejected the
value), or "validate" (user-supplied code rejected it: a Standard Schema
validator or a custom codec's parse). Core always sets it; it is optional
only so prose-only issues built outside core remain representable.
expected is the codec's bare shape label
(integer, enum(asc|desc), csv<integer>[] —
formatCodecDescription's
"shape" style), absent when no codec owns the key (a rawSearch schema
issue). wire is the offending value as the codec grammar saw it — the
value-layer string after percent-decoding, not the raw URL text (search
sources arrive platform-decoded; route segments are decoded by core
first, so a segment 1%20x records wire: "1 x"). Absent when there
isn't exactly one offending value: a missing key, a non-string source
value, or the duplicate-scalar rejection.