express-zod-router
v1.0.0
Published
FastAPI-style routing layer for Express using Zod schemas for validation, types, and OpenAPI generation.
Maintainers
Readme
express-zod-router
Declare once, validate everywhere.
A FastAPI-style routing layer for Express that eliminates boilerplate by using Zod schemas as a single source of truth for validation, types, and API documentation.
The Problem
Building Express APIs is verbose and error-prone:
// ❌ Traditional Express (keep all in sync manually)
app.post(
'/users',
validateBody(UserSchema), // validation
validateAuth, // middleware
(req, res) => {
// handler
const user = req.body; // type: unknown
res.json({ ...user }); // hope it matches OpenAPI
},
);
// Separate JSDoc/OpenAPI for docs
/**
* @route POST /users
* @param {UserSchema} body
*/Problems:
- Request/response validation separate from handler
- TypeScript types don't match runtime validation
- OpenAPI docs require JSDoc comments or external config
- Middleware scattered throughout the codebase
- Adding validation + auth + docs = 3x the code
The Solution
express-zod-router solves this in one declaration:
// ✅ express-zod-router (single source of truth)
// Generic style
api.route({
method: 'post',
path: '/users',
body: UserSchema,
response: UserSchema,
middleware: [authenticate],
handler: (req) => {
const user = req.body; // type: { id, name, email }
return user;
},
});
// Or with HTTP-method convenience shorthand
api.post('/users', {
body: UserSchema,
response: UserSchema,
middleware: [authenticate],
handler: (req) => req.body,
});Benefits:
- ✅ One declaration → validation, types, OpenAPI docs
- ✅ Full TypeScript inference → safe refactoring
- ✅ Auto-generated OpenAPI → live Swagger UI
- ✅ Router groups & middleware → clean organization
- ✅ Express compatible → drop-in replacement
Install
npm install express-zod-router express zod @asteasolutions/zod-to-openapi swagger-ui-expressTesting
Run the test suite with Vitest:
npm test
npm run test:watchFor a quick compile check without the full test run:
npm run buildQuick start
import express from 'express';
import { createApiRouter } from 'express-zod-router';
import { todoRoutes } from './routes/todo.routes';
const app = express();
app.use(express.json());
const api = createApiRouter({ prefix: '/api' });
api.routes([todoRoutes]);
api.docs({
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: 'http://localhost:3000' }],
});
api.mount(app);
app.listen(3000, () => {
console.log('API: http://localhost:3000/api');
console.log('Docs: http://localhost:3000/api-docs');
});// routes/todo.routes.ts
import { z, ApiError, type ApiRouter } from 'express-zod-router';
const TodoSchema = z
.object({
id: z.string(),
title: z.string().min(1),
completed: z.boolean(),
})
.openapi('Todo');
export function todoRoutes(api: ApiRouter) {
const todo = api.createRouter('/todos', ['Todos']);
// Convenience method style
todo.get('/:id', {
params: z.object({ id: z.string() }),
responses: {
200: { schema: TodoSchema, description: 'Todo found' },
404: { description: 'Todo not found' },
},
handler: (req) => {
const found = todos.find((t) => t.id === req.params.id);
if (!found) throw new ApiError(404, 'Todo not found');
return found;
},
});
}Why express-zod-router?
| Aspect | Express | express-zod-router | FastAPI | | ---------------- | -------------------------- | ------------------ | -------------- | | Schema | Manual JSDoc/TS interfaces | Zod schema | Pydantic | | Validation | Separate middleware | Built-in | Built-in | | Type Safety | ⚠️ Manual | ✅ Automatic | ✅ Automatic | | OpenAPI Docs | External config | Auto-generated | Auto-generated | | Middleware | Global only | Global + scoped | Built-in | | Perfect for | Minimal APIs | Async full-stack | Python async |
Core Concepts
1. Single Declaration, Three Jobs
Every route is one object: schema, validation, and documentation together. No separate app.get() + JSDoc + type interface.
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
});
api.route({
method: 'post',
path: '/users',
body: UserSchema, // ← Validates request
response: UserSchema, // ← Validates response + generates OpenAPI
handler: (req) => {
// ← req.body is typed as { id, name, email }
return req.body;
},
});One schema, three results:
- ✅ Runtime validation (Zod at request/response time)
- ✅ TypeScript types (inferred from schema)
- ✅ OpenAPI documentation (auto-generated)
2. Zod as the Source of Truth
All request/response validation uses Zod schemas. This means:
- Single source of truth — one place to change validation rules
- Runtime safety — Zod validates at runtime, not just type-check time
- Type inference — TypeScript automatically types
req.body,req.params,req.query - OpenAPI generation — schemas feed directly into Swagger docs
3. Handlers Return Data, Not Responses
Unlike Express handlers, you don't call res.json(). Just return the data:
handler: (req) => {
return { id: '1', title: 'Buy milk', completed: false };
};The framework:
- Validates the return value against your response schema
- Sends back
200 OKwith JSON - Handles errors & validation failures automatically
You can still call res.send(), res.status(), etc. when you need full control (redirects, streaming, 204 No Content).
4. Middleware at Multiple Levels
Middleware can be attached globally, to a router group, or to a single route:
// Global: runs on all routes
const api = createApiRouter({ middleware: [requestId(), logger()] });
// Router group: runs on all routes in /auth/*
const auth = api.createRouter({
path: '/auth',
middleware: [rateLimiter()],
});
// Single route: runs only on this route
api.route({
method: 'post',
path: '/users',
middleware: [authenticate, auditLog],
handler: (req) => ({ ... }),
});Middleware executes in order: global → router → route → validation → handler → response validation.
Features at a Glance
- Type Safety — Full TypeScript inference from Zod schemas
- Request Validation — Zod validation for body, params, query
- Response Validation — Ensure responses match your schema
- Auto-Generated OpenAPI — Live Swagger UI from your routes
- Router Groups — Organize routes with
createRouter(prefix, tags) - Multi-Level Middleware — Global, router-scoped, and route-level middleware
- Error Handling — Unified error handler with custom
ApiError - Express Compatible — Works with standard Express middleware
- Zero Breaking Changes — Backwards compatible with Express
- Production Ready — Used in production APIs
Getting Started in 5 Minutes
1. Define your schemas
import { z } from 'express-zod-router';
export const UserSchema = z
.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
})
.openapi('User');
export const CreateUserSchema = UserSchema.omit({ id: true });2. Create route modules
// routes/users.routes.ts
import { z, type ApiRouter } from 'express-zod-router';
import { UserSchema, CreateUserSchema } from '../schemas';
export function userRoutes(api: ApiRouter) {
const users = api.createRouter({
path: '/users',
tags: ['Users'],
middleware: [authenticate], // optional
});
users.get('/:id', {
params: z.object({ id: z.string().uuid() }),
response: UserSchema,
handler: (req) => getUserById(req.params.id),
});
users.post('/', {
body: CreateUserSchema,
response: UserSchema,
handler: (req) => createUser(req.body),
});
}3. Mount and run
// main.ts
import express from 'express';
import { createApiRouter } from 'express-zod-router';
import { userRoutes } from './routes/users.routes';
const app = express();
app.use(express.json());
const api = createApiRouter({
prefix: '/api',
middleware: [requestId(), logger()],
});
api.routes([userRoutes]);
api.docs({
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: 'http://localhost:3000' }],
});
api.mount(app);
app.listen(3000);Visit:
- API:
http://localhost:3000/api - Docs:
http://localhost:3000/api-docs
API Reference
createApiRouter(options?)
Creates a router instance with its own OpenAPI registry and optional global middleware.
const api = createApiRouter({
prefix: '/api', // optional
middleware: [requestId(), logger()], // optional
openapi: {
operationId: {
strategy: 'rest', // 'rest' | 'handler' | 'explicit'
},
},
version: {
defaultVersion: 'v1',
supportedVersions: ['v1', 'v2'],
autoTag: true,
},
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
});| Option | Type | Description |
| ----------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| prefix | string (optional) | Prepended to every route path |
| middleware | Middleware[] (opt) | Global middleware applied to all routes |
| openapi | { operationId?: { strategy?: 'rest' \| 'handler' \| 'explicit' } } (optional) | OpenAPI generation options, including operationId strategy |
| version | { defaultVersion?: ApiVersion, supportedVersions?: ApiVersion[], autoTag?: boolean } (optional) | Global API versioning defaults and validation |
| securitySchemes | Record<string, SecuritySchemeObject> (optional) | Registers OpenAPI components.securitySchemes and enables typed security references |
Returns an ApiRouter with methods: route(), createRouter(), routes(), docs(), mount(), use(), and registry.
api.route(config)
Registers a single endpoint directly on the router (no sub-prefix).
api.route({
method: 'get',
path: '/health',
response: z.object({ status: z.string() }),
handler: () => ({ status: 'ok' }),
});Config options:
| Option | Type | Description |
| --------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| method | "get" \| "post" \| "put" \| "patch" \| "delete" | HTTP method |
| path | string | Route path, e.g. /users/:id |
| operationId | string (optional) | Manual OpenAPI operation ID override. If omitted, a REST-aware ID is generated automatically |
| summary | string (optional) | Short label shown in Swagger UI |
| description | string (optional) | Longer description shown in Swagger UI |
| deprecated | boolean (optional) | Mark route as deprecated in OpenAPI spec |
| version | ApiVersion \| false (optional) | Route version. Inherits global/router defaults. false disables version inheritance for this route. ApiVersion is "2", "10", "v2", etc. |
| tags | string[] (optional) | Groups the route in Swagger UI |
| body | ZodType \| { schema: ZodType; example?: unknown } (optional) | Validates & types req.body. When using the object form, schema is required and example appears in OpenAPI |
| upload | { type: 'single'; field: string } \| { type: 'multiple'; field: string; maxFiles?: number } (optional) | Declares multipart uploads and generates multipart/form-data requestBody in OpenAPI while remaining middleware-library agnostic |
| params | ZodType (optional) | Validates & types req.params |
| query | ZodType (optional) | Validates & types req.query (supports z.coerce) |
| security | (SecuritySchemeName \| SecurityRequirement)[] (optional) | Route-level OpenAPI security metadata. Example: ['bearerAuth'] or [{ oauth2: ['users:read'] }] |
| response | ZodType \| { schema: ZodType; description?: string; example?: unknown } (optional) | Single-response shorthand — validates the return value, documents it under status |
| status | number (optional) | Status code used with response. Defaults to 200 |
| responseDescription | string (optional) | Swagger description used with response. Defaults to "Success" |
| responses | Record<number, ResponseConfig> (optional) | Multi-response map — declare every status code the route can return (see below). Takes priority over response/status for documentation |
| openapi | OpenApiOperationOverrides (optional) | Custom OpenAPI operation metadata (summary, tags, externalDocs, etc.). Merged with auto-generated content |
| handler | (req, res) => any | Return the payload directly, or call res.send()/res.json() yourself |
Returns the ApiRouter instance, so calls can be chained.
api.get / post / put / patch / delete(path, config)
Convenience shorthands for api.route() that eliminate the redundant method field. Each method accepts the same config as api.route() minus method and path:
// These two are exactly equivalent
api.route({ method: 'get', path: '/users', response: UserSchema, handler: listUsers });
api.get('/users', { response: UserSchema, handler: listUsers });All five HTTP verbs are available:
api.get('/users', { response: UserSchema.array(), handler: listUsers });
api.post('/users', { body: CreateUserSchema, response: UserSchema, handler: createUser });
api.put('/users/:id', { params: IdParams, body: UserSchema, response: UserSchema, handler: replaceUser });
api.patch('/users/:id', { params: IdParams, body: UserSchema.partial(), response: UserSchema, handler: updateUser });
api.delete('/users/:id', { params: IdParams, response: z.object({ success: z.boolean() }), handler: deleteUser });Full type inference is preserved — req.body, req.params, req.query, and the return type are all inferred from the schemas you pass, identical to api.route().
The same convenience methods are also available on scoped routers returned by createRouter() — see the createRouter section below.
Automatic operationId
express-zod-router generates REST-aware, deterministic operation IDs automatically.
Examples:
GET /users→listUsersGET /users/:id→getUserPOST /users→createUserPUT /users/:id→replaceUserPATCH /users/:id→updateUserDELETE /users/:id→deleteUserGET /users/:id/posts→listUserPostsPOST /users/:id/posts→createUserPostGET /users/:id/posts/:postId→getUserPost
You can override it manually:
api.route({
method: 'get',
path: '/users/:id',
operationId: 'fetchUserById',
response: UserSchema,
handler: getUser,
});Duplicate operationId values are rejected during registration so the generated spec stays valid.
You can configure the global operation ID strategy:
const api = createApiRouter({
openapi: {
operationId: {
strategy: 'rest', // default
},
},
});rest(default): derives IDs from method + path (contract-first)handler: uses handler function name when available; falls back to REST namingexplicit: requires every route to setoperationId
OpenAPI Metadata
Deprecated
Mark routes as deprecated in the OpenAPI spec:
api.route({
method: 'get',
path: '/users/old-endpoint',
description: 'Old endpoint - use /users/v2 instead',
deprecated: true,
response: UserSchema.array(),
handler: (req, res) => {
res.json([]);
},
});The deprecated: true flag appears in the OpenAPI spec, warning API consumers via Swagger UI.
Request & Response Examples
Add realistic examples to requests/responses for better API documentation:
// Request example
api.route({
method: 'post',
path: '/users',
body: {
schema: z.object({
name: z.string(),
email: z.string().email(),
}),
example: {
name: 'John Doe',
email: '[email protected]',
},
},
response: UserSchema,
handler: (req, res) => {
res.json({ id: 1, ...req.body });
},
});
// Response example
api.route({
method: 'get',
path: '/users/:id',
params: z.object({ id: z.coerce.number() }),
response: {
schema: UserSchema,
example: {
id: 1,
name: 'Jane Smith',
email: '[email protected]',
},
},
handler: (req, res) => {
res.json({ id: req.params.id, name: 'Jane', email: '[email protected]' });
},
});Examples appear in Swagger UI's "Example Value" sections, helping developers understand expected formats.
Custom OpenAPI Overrides
Fine-tune OpenAPI operation metadata beyond what config sugar provides:
api.route({
method: 'get',
path: '/users/search',
query: z.object({ q: z.string() }),
response: UserSchema.array(),
openapi: {
summary: 'Custom Summary Override',
tags: ['search', 'users'], // merge with route tags
externalDocs: {
url: 'https://example.com/api/search',
description: 'Learn more about user search',
},
},
handler: (req, res) => {
res.json([]);
},
});The openapi field supports any OpenAPI operation metadata (summary, tags, externalDocs, etc.). Values are merged with auto-generated content.
api.createRouter(prefix, tags?) / api.createRouter(options)
Returns a scoped route-registration function with a path prefix and default tags
baked in — equivalent to FastAPI's APIRouter(prefix=..., tags=[...]).
Scoped routers support both the generic callable style and HTTP-method convenience methods:
const todo = api.createRouter('/todos', ['Todos']);
// Generic callable style
todo({
method: 'get',
path: '/:id', // resolves to {api prefix}/todos/:id
handler: (req) => ({ id: req.params.id }),
});
// Convenience method style (equivalent)
todo.get('/:id', {
handler: (req) => ({ id: req.params.id }),
});All five HTTP methods are available on scoped routers:
const users = api.createRouter({ path: '/users', tags: ['Users'] });
users.get('/', { response: UserSchema.array(), handler: listUsers });
users.get('/:id', { params: IdParams, response: UserSchema, handler: getUser });
users.post('/', { body: CreateUserSchema, response: UserSchema, handler: createUser });
users.put('/:id', { params: IdParams, body: UserSchema, response: UserSchema, handler: replaceUser });
users.patch('/:id', { params: IdParams, body: UserSchema.partial(), response: UserSchema, handler: updateUser });
users.delete('/:id', { params: IdParams, response: z.object({ success: z.boolean() }), handler: deleteUser });Convenience methods on scoped routers inherit the router's prefix, tags, middleware, security, version, and deprecated settings exactly as the generic callable does.
You can also pass router-level middleware and security defaults:
const todo = api.createRouter({
version: 'v1',
path: '/todos',
tags: ['Todos'],
security: ['bearerAuth'],
deprecated: true,
description: 'Todo endpoints',
externalDocs: {
url: 'https://example.com/docs/todos',
description: 'Todo API docs',
},
});
todo({
method: 'get',
path: '/private',
response: z.object({ ok: z.boolean() }),
handler: () => ({ ok: true }),
});
todo({
method: 'get',
path: '/public',
version: false,
security: [],
response: z.object({ ok: z.boolean() }),
handler: () => ({ ok: true }),
});createRouter(options) also supports:
version: ApiVersion | false- Use
version: 'v2'(or'2') to mount under that version prefix. - Use
version: falseto disable inherited versioning for this router.
- Use
deprecated: boolean- Inherited by routes in this scoped router unless route-level
deprecatedis explicitly set.
- Inherited by routes in this scoped router unless route-level
description: string- Applied to the OpenAPI tag metadata for each tag in
tags(shows at the Swagger group header level, not as per-route description).
- Applied to the OpenAPI tag metadata for each tag in
externalDocs: { url: string; description?: string }- Applied to OpenAPI tag metadata for each tag in
tags.
- Applied to OpenAPI tag metadata for each tag in
When global version.autoTag is true, a version tag (like v2) is auto-added only when the route has no explicit tags. If tags are already present (for example ['Users']), the route keeps those tags and avoids duplicate grouping under both Users and v2.
Router-level metadata example:
const users = api.createRouter({
path: '/users',
tags: ['Users'],
version: '2',
deprecated: true,
description: 'Endpoints for managing users',
externalDocs: {
url: 'https://example.com/docs/users',
description: 'Users API docs',
},
});
users({
method: 'get',
path: '/:id',
response: UserSchema,
handler: (req) => ({ id: req.params.id }),
});In this example:
- The route is marked
deprecatedunless overridden on the route. descriptionandexternalDocsappear in OpenAPItagsmetadata forUsers.
api.version(version, options?)
Convenience helper that creates a scoped router bound to a version.
const v1 = api.version('v1', {
tags: ['Users'],
security: ['bearerAuth'],
});
v1({
method: 'get',
path: '/profile',
response: z.object({ ok: z.boolean() }),
handler: async () => ({ ok: true }),
});With prefix: '/api', this route resolves to /api/v1/profile.
api.routes(modules)
Registers multiple route modules at once. A route module is any function of shape
(api: ApiRouter) => void.
api.routes([userRoutes, todoRoutes, authRoutes]);This is the recommended way to organize a real app — one file per resource, each
exporting a function that registers its own routes via api.createRouter(...).
api.docs(options?)
Configures and enables OpenAPI + Swagger UI. Must be called before api.mount(app)
for docs to be served.
api.docs({
path: '/docs', // default: "/api-docs"
jsonPath: '/docs.json', // default: "/api-docs.json"
info: {
title: 'My API',
version: '1.0.0',
description: 'My Express API',
},
servers: [{ url: 'http://localhost:3000', description: 'Local development' }],
swagger: {
explorer: true,
customSiteTitle: 'My API Documentation',
options: {
swaggerOptions: {
persistAuthorization: true,
displayRequestDuration: true,
filter: true,
deepLinking: true,
docExpansion: 'list',
displayOperationId: true,
tryItOutEnabled: true,
},
},
},
});| Option | Type | Description |
| ---------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| path | string (optional) | Swagger UI route. Default /api-docs |
| jsonPath | string (optional) | Raw OpenAPI JSON route (useful for client codegen). Default /api-docs.json |
| info | { title, version, description? } | OpenAPI info block |
| servers | { url, description? }[] | OpenAPI servers block |
| openapi | object (optional) | Raw overrides merged into the generated document |
| swagger | object (optional) | Passed through to swagger-ui-express (explorer, customCss, customSiteTitle, customfavIcon, options) |
If docs() is never called, no documentation routes are mounted — useful for
disabling docs in production:
if (process.env.NODE_ENV !== 'production') {
api.docs({ info: { title: 'My API', version: '1.0.0' } });
}api.mount(app)
Attaches every registered route (and docs, if configured) onto an Express app.
Call this last, after all route() / createRouter() / routes() / docs() calls.
api.mount(app);
app.listen(3000);api.registry
Direct access to the underlying OpenAPIRegistry instance, for advanced cases
(e.g. registering shared component schemas manually).
Multipart file uploads
express-zod-router supports first-class multipart upload metadata while
staying compatible with existing Express middleware (for example Multer).
Single file
import multer from 'multer';
const upload = multer({ storage: multer.memoryStorage() });
api.post('/users/avatar', {
upload: {
type: 'single',
field: 'avatar',
},
middleware: [upload.single('avatar')],
response: z.object({ filename: z.string(), size: z.number() }),
handler: (req) => {
if (!req.file) throw new ApiError(400, 'Avatar file is required');
return {
filename: req.file.originalname,
size: req.file.size,
};
},
});Multiple files
import multer from 'multer';
const upload = multer({ storage: multer.memoryStorage() });
api.post('/documents', {
upload: {
type: 'multiple',
field: 'files',
maxFiles: 5,
},
middleware: [upload.array('files', 5)],
response: z.object({ count: z.number() }),
handler: (req) => {
const files = Array.isArray(req.files) ? req.files : [];
return { count: files.length };
},
});File + form fields
Use upload with body when the same multipart request contains files and
validated form fields:
import multer from 'multer';
const upload = multer({ storage: multer.memoryStorage() });
api.post('/products/import', {
upload: {
type: 'single',
field: 'image',
},
body: z.object({
name: z.string(),
price: z.coerce.number(),
}),
middleware: [upload.single('image')],
response: z.object({ ok: z.boolean() }),
handler: (req) => {
if (!req.file) throw new ApiError(400, 'Image is required');
return { ok: req.body.name.length > 0 && req.body.price > 0 };
},
});OpenAPI behavior
When upload is configured, the route request body is documented as
multipart/form-data and Swagger UI renders upload controls.
singlecreates a binary file field (type: string,format: binary)multiplecreates a binary array field (type: array,items: binary)body + uploadcombines file and form schemas under multipart content
The library does not force a specific upload library at runtime.
Multiple responses per route (responses)
For endpoints that can return different shapes depending on outcome — the FastAPI
responses={200: ..., 404: ...} pattern — use responses instead of response:
todo({
method: 'patch',
path: '/:id',
params: TodoIdParams,
body: CreateTodoSchema.partial(),
responses: {
200: { schema: TodoSchema, description: 'Todo updated successfully' },
404: { description: 'Todo not found' },
},
handler: (req) => {
const found = todos.find((t) => t.id === req.params.id);
if (!found) throw new ApiError(404, 'Todo not found');
Object.assign(found, req.body);
return found;
},
});Each entry in responses accepts:
| Field | Type | Description |
| ------------- | -------------------- | ------------------------------------------------------------ |
| schema | ZodType (optional) | Documents and — for the success path — validates the payload |
| description | string (optional) | Shown in Swagger UI. Defaults to "Success" |
| contentType | string (optional) | Defaults to application/json |
Swagger UI will render an example for every declared status code, not just the happy path.
reply(status, body?) helper
When using responses, you can return plain success data directly. It maps to
the first declared 2xx status (typically 200):
todo({
method: 'get',
path: '/:id',
params: TodoIdParams,
responses: {
200: { schema: TodoSchema, description: 'Todo found' },
404: { description: 'Todo not found' },
},
handler: (req) => {
const found = todos.find((t) => t.id === req.params.id);
if (!found) throw new ApiError(404, 'Todo not found');
return found; // default success path -> 200
},
});Use reply(...) when you want to set a non-default status explicitly:
import { reply } from 'express-zod-router';
todo({
method: 'get',
path: '/:id',
params: TodoIdParams,
responses: {
200: { schema: TodoSchema, description: 'Todo found' },
404: { description: 'Todo not found' },
},
handler: (req) => {
const found = todos.find((t) => t.id === req.params.id);
if (!found) return reply(404);
return reply(200, found);
},
});You can also write directly to res (return the Response):
todo({
method: 'get',
path: '/:id',
params: TodoIdParams,
responses: {
200: { schema: TodoSchema, description: 'Todo found' },
404: { description: 'Todo not found' },
},
handler: (req, res) => {
const found = todos.find((t) => t.id === req.params.id);
if (!found) {
return res.status(404).json({ error: 'Todo not found' });
}
return res.status(200).json(found);
},
});If you use responses, every code path should either:
- return plain success body (maps to first
2xxresponse), or - return
reply(status, body?), or - return
res.status(...).json(...).
This keeps TypeScript strict, so missing success paths (for example a missing
200 return) are caught at compile time.
Returning res.status(...).json(...)
Returning Express Response is supported in responses mode, as long as you
return it explicitly:
todo({
method: 'get',
path: '/:id',
params: TodoIdParams,
responses: {
200: { schema: TodoSchema, description: 'Todo found' },
404: { description: 'Todo not found' },
},
handler: (req, res) => {
const found = todos.find((t) => t.id === req.params.id);
if (!found) {
return res.status(404).json({ error: 'Todo not found' });
}
return res.status(200).json(found); // supported
},
});If you call res.status(...).json(...) without return, TypeScript will flag
the missing return path.
Handling 204 No Content
Routes that return no body are handled explicitly — return nothing and call
res.status(204).send() yourself:
todo({
method: 'delete',
path: '/:id',
params: TodoIdParams,
responses: {
204: { description: 'Todo deleted successfully' },
404: { description: 'Todo not found' },
},
handler: (req, res) => {
const index = todos.findIndex((t) => t.id === req.params.id);
if (index === -1) throw new ApiError(404, 'Todo not found');
todos.splice(index, 1);
res.status(204).send();
},
});Errors — ApiError
Throw ApiError inside any handler for a typed, structured error response. It's
caught automatically — no try/catch needed in the handler itself.
import { ApiError } from 'express-zod-router';
throw new ApiError(404, 'Todo not found');
throw new ApiError(403, 'Forbidden', { reason: 'insufficient_role' }); // optional detailsHow errors resolve, in order:
| Error type | Response |
| ---------------------------------------- | ------------------------------------------------------- |
| Zod validation error (body/params/query) | 400 { error: "Validation failed", details: [...] } |
| ApiError | { status } you passed, { error: message, details? } |
| Any other Error | 500 { error: error.message } |
| Non-Error thrown value | passed to Express's default error handling via next() |
Query coercion
Express query params always arrive as strings. Use z.coerce so numeric/boolean
query params are typed and parsed correctly:
const PaginationQuery = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});?page=2&limit=50 → req.query is typed as { page: number; limit: number },
already converted — no Number(req.query.page) in the handler.
Typed handlers in a separate file (controller pattern)
TypedRequest lets you write handler functions outside the route file with full
type inference, for the classic route / controller / service split:
// controllers/todo.controller.ts
import type { TypedRequest } from 'express-zod-router';
import { CreateTodoSchema } from '../schemas/todo.schema';
import { todoService } from '../services/todo.service';
export async function createTodo(req: TypedRequest<typeof CreateTodoSchema>) {
// req.body is typed as { title: string; completed: boolean }
return todoService.create(req.body);
}// routes/todo.routes.ts
todo({
method: 'post',
path: '',
body: CreateTodoSchema,
response: TodoSchema,
status: 201,
handler: createTodo,
});Recommended folder layout for larger apps:
schemas/ Zod schemas — single source of truth
services/ business logic, no Express/Zod imports
controllers/ thin handlers, typed via TypedRequest
routes/ wires path + schema + controller together, exports an ApiRouteModuleClient codegen from the generated spec
Since api.docs() produces a real OpenAPI document, you can generate a fully typed
client for your frontend:
npx openapi-typescript http://localhost:3000/api-docs.json -o client-types.tsLicense
MIT
