Fastify adapter
@smonn/ids/fastify provides the idParam and idQuery factories for Fastify.
Fastify is an optional peer dependency.
pnpm add fastifyidParam — route params
Section titled “idParam — route params”import { idParam, IdParamError } from "@smonn/ids/fastify";import { createTimestampId } from "@smonn/ids";
const usr = createTimestampId("usr");
// Default: throws IdParamError → setErrorHandler renders itfastify.get("/users/:id", { preHandler: idParam("id", usr) }, (request, reply) => { const id = request.params.id; // string (compile-time); Id<"usr"> at runtime after preHandler});
// Error handler receives the typed errorfastify.setErrorHandler((err, request, reply) => { if (err instanceof IdParamError) { reply.status(err.statusCode).send({ error: err.reason }); return; } reply.send(err);});--strictFunctionTypes note: idParam returns a hook typed as
(request: FastifyRequest<{ Params: Record<string, Id<Brand>> }>, reply: FastifyReply) => Promise<void>.
Assigning it directly to a preHandler slot is safe — TypeScript’s
method-signature bivariance applies to Fastify’s preHandler slot definition. If you instead store the hook in a
locally-annotated variable typed as the bare
(request: FastifyRequest, reply: FastifyReply) => Promise<void>,
TypeScript will report an error under --strictFunctionTypes because function
parameter types are contravariant. Avoid the explicit annotation and let
TypeScript infer, or use preHandler slot assignment directly.
- Default error channel: on failure the adapter throws
IdParamErrorcarryingstatusCodeandreason— Fastify’ssetErrorHandlercontrols rendering. options.onError: when provided, the adapter awaits the hook on validation failure. If the hook sends a response (reply.sent), the adapter takes no further action. If the hook does not respond, the adapter throwsIdParamError, so the route handler never runs with an invalid ID.options.status: remaps the default HTTP status for a failure reason.
idQuery — query-string params
Section titled “idQuery — query-string params”import { idQuery, IdParamError } from "@smonn/ids/fastify";import { createTimestampId } from "@smonn/ids";
const usr = createTimestampId("usr");
// GET /users?userId=usr_...fastify.get("/users", { preHandler: idQuery("userId", usr) }, (request, reply) => { const userId = request.query.userId; // string (compile-time); Id<"usr"> at runtime});
// Override: consumer fully owns the error responsefastify.get( "/search", { preHandler: idQuery("cursor", usr, { onError: (failure, request, reply) => reply.status(failure.status).send({ error: failure.reason }), }), }, handler,);Same options shape and failure contract as idParam — same IdParamOptions,
same IdParamError thrown into setErrorHandler, same onError / status —
but reads request.query[name] instead
of request.params[name]. A missing query param is treated as malformed (status
400). The canonical Id<Brand> is stored in request.query under the query name.
IdParamFailure shape
Section titled “IdParamFailure shape”The onError callback receives an IdParamFailure — a discriminated union on reason:
type IdParamFailure = | { readonly reason: "brand_mismatch"; readonly status: number } | { readonly reason: "malformed"; readonly status: number };reason: "brand_mismatch"— the ID has a valid structure but belongs to a different brand; defaultstatusis 404.reason: "malformed"— the ID is syntactically invalid; defaultstatusis 400.statusreflects any override set viaoptions.status, otherwise the default above.
IdParamFailure is re-exported from @smonn/ids/fastify — no separate import from
"@smonn/ids" is needed.
The 400 vs 404 defaults match the Hono and
Express adapters: reason: "brand_mismatch" → 404,
reason: "malformed" → 400. For idParam, the canonical Id<Brand> is stored
in request.params under the param name; for idQuery, it is stored in
request.query under the query name.
Signature verification
Section titled “Signature verification”Pass verify: true together with a Signed Timestamp codec or a Wrapped key codec to authenticate the tag after structural parsing succeeds. TypeScript enforces this at the call site via function overloads — { verify: true } is a type error when paired with a non-verifiable codec (Timestamp, Opaque, Reverse Timestamp, Digest).
import { idParam } from "@smonn/ids/fastify";import { createSignedTimestampId, importSigningKey } from "@smonn/ids/signed";
const key = await importSigningKey(new Uint8Array(32));const usr = createSignedTimestampId("usr", { keys: [key] });
fastify.get( "/users/:id", { preHandler: idParam("id", usr, { verify: true }) }, (request, reply) => { const id = request.params.id; // Id<"usr"> at runtime, structurally parsed AND HMAC-verified },);When verify: true is set:
- The adapter first runs
codec.safeParse— a parse failure follows the normal error channel (brand mismatch → 404, malformed → 400). - If parsing succeeds,
codec.safeVerify(raw)is awaited. A tag failure is treated asreason: "malformed"and routed through the same error channel (status 400 by default, overrideable viaoptions.status.malformed).
For the Signed Timestamp codec, safeVerify checks the HMAC tag. For the Wrapped key codec, safeVerify is a verify-only alias of safeUnwrap (it drops the recovered lookup key); a wrong-key, tampered, or revoked-key ID surfaces as the same "malformed" failure.
Without verify: true, the adapter calls only safeParse — the default behaviour is byte-for-byte unchanged and no async work is added.