paramour
Referenceparamour (core)

p.* builders

Every built-in codec — wire grammars, accept/reject behavior, and schema slots.

The p object is the catalog of codec builders. Each builder returns a codec — a bidirectional wire converter that both parses a URL string into a typed value and serializes that value back. Parsing is strict: each codec matches an anchored grammar from the wire-format spec, never Number()-style coercion, so "4.2" is not an integer, " 42" is not a number, and "0x10" is not sixteen. Malformed input fails with a structured ParseError; an unserializable value fails with a SerializeError at link-build time.

The tables below show value-layer strings — what the codec sees after percent-decoding. Byte-layer encoding (spaces as %20, commas as %2C) is applied by the search and path layers, not by the codec.

Four builders take an optional Standard Schema for domain refinement (p.string, p.integer, p.number, p.index); p.json requires one. A schema validates in both directions: schema-invalid wire input is a ParseError, and a schema-invalid in-memory value is a SerializeError when you build the link — never a URL that can't round-trip.

p.string

The identity codec: the decoded param text, unchanged, in both directions.

p.string() produces Codec<string>. With a schema (StandardSchemaV1<string, string>) the codec's output type becomes the schema's output type, so branded or refined string types flow through:

import { ,  } from "paramour";
import {  } from "zod";

const  = { : .(.().(1)) };

const search = (, new ("?q=socks"));
const search: InferSearchOutput<{
    q: Codec<string, "required", false, "single", boolean>;
}>

The schema's returned value is what goes on the wire, so a normalizing schema (trim, lowercase) emits canonical form on serialize. Transforming schemas — where input and output types differ — are parse-only by design: their output fails input validation on the serialize side. Use p.custom for bidirectional transforms.

p.integer

Base-10 integers, matched by the anchored grammar -?\d+ plus a safe-integer bound check.

import { ,  } from "paramour";

const  = (
  { : .() },
  new ("?page=42"),
);

.page;
page: number
Wire valueResult
4242
-7-7
0077 (non-canonical; re-serializes as 7)
4.2ParseError — not an integer
1e3ParseError — no scientific notation
+5, 42, 0x10ParseError — anchored grammar
9007199254740993ParseError — outside the safe integer range

Serialization requires a finite safe integer — anything else (a float, NaN, a number beyond Number.MAX_SAFE_INTEGER) throws SerializeError. The optional schema (StandardSchemaV1<number, number>) refines the parsed integer and re-validates on serialize.

p.number

Finite base-10 numbers: an integer part, an optional decimal part, and an optional exponent (-?\d+(\.\d+)?([eE][+-]?\d+)?).

import { ,  } from "paramour";

const  = (
  { : .() },
  new ("?ratio=2.5e-1"),
);

.ratio;
ratio: number
Wire valueResult
3.143.14
-0.5-0.5
1e31000
.5ParseError — leading digit required
5.ParseError — trailing dot
NaN, InfinityParseError — grammar, then finiteness
42, 0x2AParseError — anchored grammar

Serialization is String(value) for a finite number; NaN and the infinities throw SerializeError. The optional schema is StandardSchemaV1<number, number>, applied on both sides.

p.boolean

Exactly "true" or "false" on the wire — nothing else.

import { ,  } from "paramour";

const  = ({ : .() }, { : true });
// => "?debug=true"
Wire valueResult
truetrue
falsefalse
1, TRUE, empty stringParseError — not "true" or "false"

p.enum

A closed set of string members. The const type parameter keeps the literal union, so the output narrows to exactly the members you listed:

import { ,  } from "paramour";

const  = (
  { : .(["price", "rating"]) },
  new ("?sort=price"),
);

.sort;
sort: "price" | "rating"

Parsing rejects any value outside the set with a ParseError naming the members; serializing a non-member (possible from plain JavaScript, or via a cast) throws SerializeError. The member list must be a non-empty tuple of strings, and it is exposed to reflection as enumMembers.

p.isoDate

Calendar dates as YYYY-MM-DD, decoded to a UTC-midnight Date.

import { ,  } from "paramour";

const  = (
  { : .() },
  new ("?since=2026-07-18"),
);

.since;
since: Date
Wire valueResult
2026-07-18Date at 2026-07-18T00:00:00.000Z
2026-02-30ParseError — not a real calendar date
2026-7-4ParseError — two-digit fields required
2026-07-18T00:00:00ZParseError — use p.timestamp for instants

Dates the engine would silently normalize are rejected: 2026-02-30 fails rather than becoming March 2nd. Serialization emits the date part of toISOString(); an invalid Date throws SerializeError, as does a year outside 0000–9999 (where toISOString switches to an expanded-year form the wire grammar cannot represent — better a loud error than a URL that can never round-trip).

p.timestamp

Full ISO-8601 UTC instants, decoded to a Date. Canonical output is Date#toISOString() — milliseconds always present — while parsing tolerates absent or 1–3-digit milliseconds. UTC (Z) only: offsets are rejected.

import { ,  } from "paramour";

const  = (
  { : .() },
  new ("?at=2026-07-18T12:30:00Z"),
);

.at;
at: Date
Wire valueResult
2026-07-18T12:30:00.000ZDate (canonical form)
2026-07-18T12:30:00ZDate (milliseconds optional on parse)
2026-07-18T12:30:00+02:00ParseError — UTC Z only
2026-02-30T00:00:00ZParseError — not a real instant
2026-07-18ParseError — use p.isoDate for dates

Like p.isoDate, impossible instants the engine would normalize (Feb 30, hour 24) are rejected via an exact round-trip check, and serialization applies the same valid-Date, year-0000–9999 guards.

p.json

Any JSON value packed into a single param, validated by a required Standard Schema — unvalidated JSON.parse output would poison the types, so there is no schemaless form:

import { ,  } from "paramour";
import {  } from "zod";

const  = { : .(.({ : .() })) };

const  = (
  ,
  new ('?filters={"maxPrice":250}'),
);

.filters;
filters: {
    maxPrice: number;
}

Parsing is JSON.parse (a syntax failure is a ParseError) followed by schema validation. Serializing validates first, then JSON.stringify — values JSON cannot represent (undefined, functions, symbols, circular references, BigInt) throw SerializeError rather than leaking a raw TypeError. On the wire the JSON text is percent-encoded by the byte layer (?filters=%7B%22maxPrice%22%3A250%7D).

p.csv

A comma-separated list packed into one wire value (?tags=a,b), arity "single". Elements are strings unless you pass an element codec:

import { ,  } from "paramour";

const  = (
  { : .(.()) },
  new ("?ids=1,2,3"),
);

.ids;
ids: number[]
Wire valueResult
a,b["a", "b"]
empty string[]
a,,bParseError — empty list element
a, or ,ParseError — strict grammar

The grammar is deliberately strict: the empty wire string means [], and every segment must be non-empty and parse under the element codec — the first element failure aborts the list parse. Recovery, if you want it, belongs on the list via .catch().

Serialization guards the round trip: an element that serializes to the empty string, or whose serialization contains a comma, throws SerializeError (it would mis-split on re-parse). A consequence: [""] is unrepresentable — the empty wire string already means [].

Because the arity is "single", the full modifier set applies — p.csv().default([]), .optional(), .catch() all work. Element codecs, however, must be plain scalars: modifiers and array codecs are rejected at compile time.

import {  } from "paramour";

.(p.integer().catch(0));
Argument of type 'Codec<number, "required", true, "single", boolean>' is not assignable to parameter of type 'Codec<number, "required", false, "single", boolean>'. The types of 'default(...)["~caught"]' are incompatible between these types. Type 'true' is not assignable to type 'false'.

A nested csv (p.csv(p.csv())) is structurally indistinguishable at the type level and is instead rejected at runtime with a ParamourError.

p.array

A repeated-key list (?tags=a&tags=b), arity "many". Like p.csv, elements default to strings and an element codec changes the element type:

import { ,  } from "paramour";

const  = (
  { : .() },
  new ("?tags=sale&tags=new"),
);

.tags;
tags: string[]
Wire valueResult
?tags=a&tags=b["a", "b"]
key absent[]
?tags=a["a"]

Absence and [] are the same wire state, so presence modifiers do not exist on array codecs — a .default() could never be observed, and .optional() would promise a distinction the URL can't make. The methods are typed never:

import {  } from "paramour";

.().default(["a"]);
This expression is not callable. Type 'never' has no call signatures.
.().optional();
This expression is not callable. Type 'never' has no call signatures.

.catch() stays legal and preserves the "many" arity. Element restrictions mirror p.csv — no modifiers, no arity-"many" elements:

import {  } from "paramour";

.(p.integer().optional());
Argument of type 'Codec<number, "optional", false, "single", boolean>' is not assignable to parameter of type 'Codec<number, "required", false, "single", boolean>'. The types of 'catch(...)["~presence"]' are incompatible between these types. Type '"optional"' is not assignable to type '"required"'.

Unlike csv, a csv element is legal here: p.array(p.csv(p.integer())) means one whole comma-packed list per repeated key (?m=1,2&m=3,4 decodes to [[1, 2], [3, 4]]).

p.index

A pagination-friendly integer that is 1-based on the wire and 0-based in memory: ?page=1 decodes to index 0.

import { ,  } from "paramour";

const  = ({ : .() }, { : 0 });
// => "?page=1"
Wire valueResult
10
109
0ParseError — below the 1-based wire floor
-1ParseError — below the 1-based wire floor
1.5ParseError — integer grammar

Wire values below 1 are a ParseError (recoverable via .catch(), like any malformed input). A negative in-memory index cannot round-trip through the 1-based wire floor, so serializing one is a SerializeError at link-build time. The optional schema (StandardSchemaV1<number, number>) validates the in-memory, 0-based value on both sides.

p.custom

The escape hatch: wrap any bidirectional parse/serialize pair. Out is inferred from the pair:

import {  } from "paramour";

const  = .({
  : "bigint",
  : () => (),
  : (: bigint) => .(),
});

bigintCodec;
const bigintCodec: Codec<bigint, "required", false, "single", boolean>
  • parse: (raw: string) => Out — throw for malformed input. Foreign throws (like BigInt's SyntaxError above) are rebranded to ParseError, so they are recoverable via .catch() and aggregate per-key like any other parse failure.
  • serialize: (value: Out) => string — foreign throws are rebranded to SerializeError.
  • label (optional) — the reflection name shown by describeCodec and paramour list; defaults to "custom".

Paramour errors pass through loud

Any ParamourError thrown inside your parse/serialize — including errors from reused paramour helpers — is never downgraded: it bypasses .catch() recovery and per-key aggregation. .catch() recovers foreign parse failures only.

All three modifiers work on the result, and p.custom codecs are legal p.csv/p.array elements as long as the usual element rules hold (an element serialization containing a comma is caught by csv's serialize guard).

On this page