fastify-lor-zod
v0.1.7
Published
A Fastify type provider integrating Zod for schema validation and type-safe route definitions
Maintainers
Readme
fastify-lor-zod
Note -- Pre-1.0: minor versions may include breaking changes. Pin your version and check the changelog before upgrading.
A Fastify type provider for Zod v4 with full OpenAPI support.
Built with good vibes for Fastify v5 and Zod v4. Fixes issues from turkerdev/fastify-type-provider-zod.
Why fastify-lor-zod?
- Zod v4 native -- uses
safeEncode,toJSONSchema, codecs, and registries directly - Smart serializer -- auto-detects codecs at compile time; falls back to
safeParsefor ~15% faster non-codec schemas - Complete OpenAPI -- all HTTP parts, nullable types, discriminated unions, recursive schemas, content types
- Type-safe end-to-end --
req.body,req.params,req.query,req.headers, andreply.send()fully typed - 100% test coverage -- 127 tests including snapshot parity with
fastify-type-provider-zod - Why "Lor"? -- Son of Zod, here to power your
fastifyschemas.
Table of Contents
- Install
- Quick Start
- Serializer Compilers
- OpenAPI / Swagger
- Typed Plugins
- Error Handling
- Zod v4 Codec Support
- Compatibility
- Issues Addressed
- Contributing
- License
Install
pnpm add fastify-lor-zod
pnpm add -D fastify zod # peer dependencies
pnpm add -D @fastify/swagger # optional, for OpenAPIQuick Start
import Fastify from 'fastify';
import { z } from 'zod';
import {
validatorCompiler,
serializerCompiler,
type FastifyLorZodTypeProvider,
} from 'fastify-lor-zod';
const app = Fastify();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
app.withTypeProvider<FastifyLorZodTypeProvider>().get(
'/user/:id',
{
schema: {
params: z.object({ id: z.coerce.number() }),
response: {
200: z.object({ id: z.number(), name: z.string() }),
},
},
},
(req) => ({ id: req.params.id, name: 'Alice' }),
// ^ fully typed as number
);
app.listen({ port: 3000 });Serializer Compilers
Three strategies for different trade-offs:
| Compiler | Validates | Codecs | Speed | Use when |
|----------|-----------|--------|-------|----------|
| serializerCompiler | Yes | Auto-detect | Fastest validating | Recommended default -- uses safeParse for plain schemas, safeEncode only when codecs are present |
| parseSerializerCompiler | Yes | No | Same as above | Explicit opt-in to always use safeParse |
| fastSerializerCompiler | No | No | Fastest overall | You trust your handlers and want maximum throughput |
import {
serializerCompiler, // default: auto-detects codecs, picks safeParse or safeEncode
parseSerializerCompiler, // always z.safeParse + JSON.stringify
fastSerializerCompiler, // fast-json-stringify, no validation
} from 'fastify-lor-zod';
app.setSerializerCompiler(serializerCompiler);createSerializerCompiler and createParseSerializerCompiler each accept a replacer option for JSON.stringify. createFastSerializerCompiler takes no options — fast-json-stringify pre-compiles the serializer at route registration time and does not use JSON.stringify.
Benchmarks
Serialization throughput (ops/sec, higher is better):
| Scenario | lor-zod | lor-zod (parse) | lor-zod (fast) | type-provider-zod | zod-openapi | |----------|---------|-----------------|----------------|-------------------|-------------| | Simple object | 278K | 287K | 610K | 291K | 271K | | Simple object + date codec | 142K | Unsupported | 211K | Unsupported | Unsupported | | Nested (10 items) | 33K | 34K | 86K | 34K | 30K | | Nested + money codec | 29K | Unsupported | 90K | Unsupported | Unsupported | | Discriminated union | 499K | 487K | 651K | 505K | 316K | | Recursive tree | 407K | 383K | 1.13M | 397K | 438K |
For non-codec schemas, serializerCompiler auto-detects and matches parseSerializerCompiler speed. For codec schemas, it automatically uses safeEncode.
Validation throughput (all libraries are within ~5% of each other):
| Scenario | lor-zod | type-provider-zod | zod-openapi | |----------|---------|-------------------|-------------| | Simple object | 386K | 360K | 366K | | Nested (10 items) | 57K | 57K | 58K | | Discriminated union | 996K | 946K | 933K | | Recursive tree | 819K | 805K | 758K |
Measured on Apple M-series, Node.js 24, Zod 4.3.6. Run
pnpm benchto reproduce, orpnpm bench:lib lor-zodfor this library only.
OpenAPI / Swagger
The library provides two @fastify/swagger hooks: transform (converts Zod schemas per route) and transformObject (populates components.schemas from a registry). Which ones you need depends on whether you use a schema registry:
- No registered schemas —
transformalone is sufficient. All schemas are inlined. - With registered schemas (via
z.globalRegistryor a custom registry) — use both.transformemits$refs for registered schemas,transformObjectprovides the component definitions they point to.
Basic Setup (no registry)
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';
import { jsonSchemaTransform } from 'fastify-lor-zod';
await app.register(swagger, {
openapi: {
openapi: '3.0.3',
info: { title: 'My API', version: '1.0.0' },
},
transform: jsonSchemaTransform,
});
await app.register(swaggerUi, { routePrefix: '/documentation' });With a Registry
When using z.globalRegistry or a custom registry, add transformObject to populate components.schemas with $ref-based definitions:
import { jsonSchemaTransform, jsonSchemaTransformObject } from 'fastify-lor-zod';
await app.register(swagger, {
openapi: {
openapi: '3.0.3',
info: { title: 'My API', version: '1.0.0' },
},
transform: jsonSchemaTransform,
transformObject: jsonSchemaTransformObject,
});OpenAPI Features
- OAS 3.0 and 3.1 support
- Automatic
io: "input"for request schemas,io: "output"for response schemas z.registry()andz.globalRegistryresolve to$refcomponents- Nullable types, discriminated unions, recursive schemas handled correctly
- Nested content types in body and response (
application/json,multipart/form-data, etc.) - Response
descriptionpreserved from wrapper objects zodToJsonConfigpassthrough for customz.toJSONSchema()options
Custom Schema Registry
For a custom registry (instead of z.globalRegistry), use the factory functions with shared options:
import { z } from 'zod';
import { createJsonSchemaTransforms } from 'fastify-lor-zod';
const registry = z.registry<{ id: string }>();
const UserSchema = z.object({ id: z.number(), name: z.string() });
registry.add(UserSchema, { id: 'User' });
await app.register(swagger, {
openapi: { openapi: '3.0.3', info: { title: 'My API', version: '1.0.0' } },
...createJsonSchemaTransforms({ schemaRegistry: registry }),
});The individual factories are also available — useful when you need different options per function or want to pass pre-computed divergentIds:
import {
createJsonSchemaTransform,
createJsonSchemaTransformObject,
} from 'fastify-lor-zod';
const opts = { schemaRegistry: registry };
await app.register(swagger, {
openapi: { openapi: '3.0.3', info: { title: 'My API', version: '1.0.0' } },
transform: createJsonSchemaTransform(opts),
transformObject: createJsonSchemaTransformObject(opts),
});Input/Output Schema Variants
Zod schemas used as request bodies are processed with io: "input", while response schemas use io: "output". For most schemas these are identical, but schemas with transforms, codecs, or defaults can produce different input and output shapes.
Input variants are auto-detected — when a registered schema's input and output JSON Schema representations differ, an {Id}Input component is automatically generated alongside the output variant. No configuration needed.
// CreateUserSchema has role: z.string().default('user')
// Input shape: role is optional. Output shape: role is always present.
registry.add(CreateUserSchema, { id: 'CreateUser' });
await app.register(swagger, {
openapi: { openapi: '3.0.3', info: { title: 'My API', version: '1.0.0' } },
...createJsonSchemaTransforms({ schemaRegistry: registry }),
});
// components.schemas will contain both (auto-detected):
// CreateUser — output shape (role required)
// CreateUserInput — input shape (role optional, with default)
// requestBody $ref → #/components/schemas/CreateUserInput
// response $ref → #/components/schemas/CreateUserTyped Plugins
import type { FastifyPluginAsyncZod } from 'fastify-lor-zod';
const usersPlugin: FastifyPluginAsyncZod = async (app) => {
app.get(
'/users',
{
schema: {
response: { 200: z.array(UserSchema) },
},
},
() => [{ id: 1, name: 'Alice' }],
);
};
await app.register(usersPlugin);Error Handling
Both error classes use modern ES2022+ patterns with instanceof support, a stable code property for programmatic matching, and cause chaining via ErrorOptions.
import {
RequestValidationError,
ResponseSerializationError,
} from 'fastify-lor-zod';
app.setErrorHandler((error, request, reply) => {
if (error instanceof RequestValidationError) {
reply.code(400).send({
error: 'Validation failed',
issues: error.validation,
});
return;
}
if (error instanceof ResponseSerializationError) {
reply.code(500).send({
error: 'Response serialization failed',
code: error.code, // 'ERR_RESPONSE_SERIALIZATION'
method: error.method,
url: error.url,
});
return;
}
reply.send(error);
});Zod v4 Codec Support
Zod v4 codecs encode domain types to wire format. The default serializer handles this automatically:
const dateCodec = z.codec(z.iso.datetime(), z.date(), {
decode: (iso: string) => new Date(iso),
encode: (date: Date) => date.toISOString(),
});
app.get(
'/event',
{
schema: {
response: {
200: z.object({ startsAt: dateCodec }),
},
},
},
() => ({ startsAt: new Date() }),
// Response: { "startsAt": "2025-06-15T10:00:00.000Z" }
);Compatibility
| fastify-lor-zod | Fastify | Zod | @fastify/swagger | fast-json-stringify | Node.js |
|-----------------|---------|-----|------------------|---------------------|---------|
| 0.1.x | >= 5.8.4 | >= 4.3.6 | >= 9.7.0 (optional) | >= 6.3.0 (optional, for fastSerializerCompiler) | >= 24 |
Migrating from fastify-type-provider-zod
See MIGRATION.md for a step-by-step guide.
Issues Addressed
Fixes issues from turkerdev/fastify-type-provider-zod:
| Issue | Description |
|-------|-------------|
| #244 | params/querystring missing from OpenAPI |
| #233 | Cannot tweak toJSONSchema options |
| #214 | Input schema variants leak into components |
| #211 | Serializer should use .encode() for Zod v4 |
| #210 | Schema definitions ignored |
| #209 | Cannot modify headers after validation |
| #208 | transform() loses response type info |
| #195 | anyOf with 3+ items broken |
| #193 | Nullable types converted incorrectly |
| #192 | z.null in union generates invalid JSON Schema |
| #178 | Multi-content schemas not supported |
| #170 | components.schemas not populated |
| #158 | .default(null) crashes |
| #155 | .optional().default() querystring fails |
| #148 | Optional fields treated as required |
| #132 | Body/response content types not handled |
| #71 | z.readonly() not supported |
| #47 | Response description ignored |
Contributing
git clone https://github.com/drudolf/fastify-lor-zod.git
cd fastify-lor-zod
pnpm install| Command | Description |
|---------|-------------|
| pnpm test | Run tests |
| pnpm test:coverage | Run tests with 100% coverage enforcement |
| pnpm check | Lint + format (Biome) |
| pnpm typecheck | Type-check with tsc --noEmit |
| pnpm knip | Detect unused exports and dependencies |
| pnpm bench | Run benchmarks against all type providers |
| pnpm bench:lib <filter> | Run benchmarks for a single library (e.g. lor-zod, type-provider, zod-openapi) |
| pnpm build | Build the project (ESM and CJS) |
Tests follow a spec-first workflow -- see test-spec.md for the full test matrix and CLAUDE.md for project conventions.
License
MIT
