Every REST API needs input validation and documentation. Most teams treat these as separate concerns — writing Joi schemas for validation, then manually maintaining OpenAPI specs. They inevitably drift apart. Here'''s how to use Zod as the single source of truth for both.
What We'''re Building
A complete REST API for a task management system with:
- Request/response validation using Zod schemas
- Automatic OpenAPI 3.1 spec generation from those schemas
- Type-safe request handlers (no
anytypes) - Proper error responses with structured validation messages
Prerequisites
- Node.js 20+ and npm
- Basic TypeScript and Express knowledge
- Familiarity with REST API concepts
Project Setup
mkdir task-api && cd task-api
npm init -y
npm install express zod zod-to-openapi uuid
npm install -D typescript @types/express @types/uuid tsx
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}
Step 1: Define Schemas as the Source of Truth
// src/schemas/task.ts
import { z } from '''zod''';
import { extendZodWithOpenApi } from '''zod-to-openapi''';
extendZodWithOpenApi(z);
// Base task schema — represents what'''s stored
export const TaskSchema = z.object({
id: z.string().uuid().openapi({ example: '''550e8400-e29b-41d4-a716-446655440000''' }),
title: z.string().min(1).max(200).openapi({ example: '''Ship v2.0 release''' }),
description: z.string().max(2000).optional().openapi({ example: '''Final QA pass and deploy''' }),
status: z.enum(['''todo''', '''in_progress''', '''done''']).openapi({ example: '''todo''' }),
priority: z.enum(['''low''', '''medium''', '''high''', '''critical''']).openapi({ example: '''high''' }),
assignee: z.string().email().optional().openapi({ example: '''dev@company.com''' }),
dueDate: z.string().datetime().optional().openapi({ example: '''2025-06-15T00:00:00Z''' }),
tags: z.array(z.string().max(50)).max(10).default([]).openapi({ example: ['''backend''', '''release'''] }),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
}).openapi('''Task''');
// Create request — subset of fields
export const CreateTaskSchema = TaskSchema.pick({
title: true,
description: true,
priority: true,
assignee: true,
dueDate: true,
tags: true,
}).extend({
title: z.string().min(1).max(200),
}).openapi('''CreateTask''');
// Update request — all fields optional
export const UpdateTaskSchema = CreateTaskSchema.partial().openapi('''UpdateTask''');
// Query parameters for listing
export const TaskQuerySchema = z.object({
status: z.enum(['''todo''', '''in_progress''', '''done''']).optional(),
priority: z.enum(['''low''', '''medium''', '''high''', '''critical''']).optional(),
assignee: z.string().email().optional(),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['''createdAt''', '''updatedAt''', '''dueDate''', '''priority''']).default('''createdAt'''),
order: z.enum(['''asc''', '''desc''']).default('''desc'''),
}).openapi('''TaskQuery''');
// Derive TypeScript types directly from schemas
export type Task = z.infer<typeof TaskSchema>;
export type CreateTask = z.infer<typeof CreateTaskSchema>;
export type UpdateTask = z.infer<typeof UpdateTaskSchema>;
export type TaskQuery = z.infer<typeof TaskQuerySchema>;
Every type, every validation rule, every OpenAPI example lives in one place. Change the schema, and validation, types, and docs all update together.
Step 2: Type-Safe Validation Middleware
// src/middleware/validate.ts
import { Request, Response, NextFunction } from '''express''';
import { z, ZodError, ZodSchema } from '''zod''';
type ValidationTarget = '''body''' | '''query''' | '''params''';
interface ValidationSchema {
body?: ZodSchema;
query?: ZodSchema;
params?: ZodSchema;
}
function formatZodError(error: ZodError) {
return {
error: '''Validation failed''',
details: error.issues.map((issue) => ({
path: issue.path.join('''.'''),
message: issue.message,
code: issue.code,
})),
};
}
export function validate(schemas: ValidationSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const targets: ValidationTarget[] = ['''body''', '''query''', '''params'''];
const errors: z.ZodIssue[] = [];
for (const target of targets) {
const schema = schemas[target];
if (!schema) continue;
const result = schema.safeParse(req[target]);
if (!result.success) {
for (const issue of result.error.issues) {
issue.path = [target, ...issue.path];
errors.push(issue);
}
} else {
(req as any)[target] = result.data;
}
}
if (errors.length > 0) {
const zodError = new ZodError(errors);
return res.status(400).json(formatZodError(zodError));
}
next();
};
}
Two details matter here. First, safeParse returns the transformed data (coerced numbers, defaults applied), and we replace req.body/req.query with it. Downstream handlers get clean, typed data. Second, we validate all targets before responding, so the client sees every error at once.
Step 3: Request Handlers with Full Type Safety
// src/routes/tasks.ts
import { Router, Request, Response } from '''express''';
import { v4 as uuidv4 } from '''uuid''';
import { validate } from '''../middleware/validate.js''';
import {
CreateTaskSchema,
UpdateTaskSchema,
TaskQuerySchema,
type Task,
type CreateTask,
type TaskQuery,
} from '''../schemas/task.js''';
import { z } from '''zod''';
const router = Router();
const tasks = new Map<string, Task>();
const IdParamSchema = z.object({ id: z.string().uuid() });
// LIST tasks with filtering, pagination, sorting
router.get('''/''',
validate({ query: TaskQuerySchema }),
(req: Request, res: Response) => {
const query = req.query as unknown as TaskQuery;
let results = Array.from(tasks.values());
if (query.status) results = results.filter((t) => t.status === query.status);
if (query.priority) results = results.filter((t) => t.priority === query.priority);
if (query.assignee) results = results.filter((t) => t.assignee === query.assignee);
results.sort((a, b) => {
const aVal = a[query.sortBy] ?? '''''';
const bVal = b[query.sortBy] ?? '''''';
const cmp = String(aVal).localeCompare(String(bVal));
return query.order === '''asc''' ? cmp : -cmp;
});
const total = results.length;
const start = (query.page - 1) * query.limit;
const items = results.slice(start, start + query.limit);
res.json({
items,
pagination: { page: query.page, limit: query.limit, total, totalPages: Math.ceil(total / query.limit) },
});
}
);
// CREATE task
router.post('''/''',
validate({ body: CreateTaskSchema }),
(req: Request, res: Response) => {
const input = req.body as CreateTask;
const now = new Date().toISOString();
const task: Task = {
id: uuidv4(),
status: '''todo''',
...input,
tags: input.tags ?? [],
createdAt: now,
updatedAt: now,
};
tasks.set(task.id, task);
res.status(201).json(task);
}
);
// GET single task
router.get('''/:id''',
validate({ params: IdParamSchema }),
(req: Request, res: Response) => {
const task = tasks.get(req.params.id);
if (!task) return res.status(404).json({ error: '''Task not found''' });
res.json(task);
}
);
// UPDATE task
router.patch('''/:id''',
validate({ params: IdParamSchema, body: UpdateTaskSchema }),
(req: Request, res: Response) => {
const existing = tasks.get(req.params.id);
if (!existing) return res.status(404).json({ error: '''Task not found''' });
const updated: Task = {
...existing,
...req.body,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
tasks.set(updated.id, updated);
res.json(updated);
}
);
// DELETE task
router.delete('''/:id''',
validate({ params: IdParamSchema }),
(req: Request, res: Response) => {
if (!tasks.delete(req.params.id)) return res.status(404).json({ error: '''Task not found''' });
res.status(204).send();
}
);
export default router;
Notice there are zero manual type assertions beyond the initial cast from req.query. The validation middleware guarantees the shape, and TypeScript carries it forward.
Step 4: Auto-Generate OpenAPI Spec
// src/openapi.ts
import { OpenAPIRegistry, OpenApiGeneratorV31 } from '''zod-to-openapi''';
import { TaskSchema, CreateTaskSchema, UpdateTaskSchema, TaskQuerySchema } from '''./schemas/task.js''';
import { z } from '''zod''';
const registry = new OpenAPIRegistry();
registry.register('''Task''', TaskSchema);
registry.register('''CreateTask''', CreateTaskSchema);
registry.register('''UpdateTask''', UpdateTaskSchema);
const IdParam = registry.registerParameter('''taskId''',
z.string().uuid().openapi({ param: { name: '''id''', in: '''path''' } })
);
registry.registerPath({
method: '''get''',
path: '''/api/tasks''',
summary: '''List tasks with filtering and pagination''',
request: { query: TaskQuerySchema },
responses: {
200: {
description: '''Paginated task list''',
content: {
'''application/json''': {
schema: z.object({
items: z.array(TaskSchema),
pagination: z.object({
page: z.number(), limit: z.number(),
total: z.number(), totalPages: z.number(),
}),
}),
},
},
},
},
});
registry.registerPath({
method: '''post''',
path: '''/api/tasks''',
summary: '''Create a new task''',
request: { body: { content: { '''application/json''': { schema: CreateTaskSchema } } } },
responses: {
201: { description: '''Task created''', content: { '''application/json''': { schema: TaskSchema } } },
400: { description: '''Validation error''' },
},
});
registry.registerPath({
method: '''get''',
path: '''/api/tasks/{id}''',
summary: '''Get a task by ID''',
request: { params: z.object({ id: IdParam }) },
responses: {
200: { description: '''Task found''', content: { '''application/json''': { schema: TaskSchema } } },
404: { description: '''Task not found''' },
},
});
registry.registerPath({
method: '''patch''',
path: '''/api/tasks/{id}''',
summary: '''Update a task''',
request: {
params: z.object({ id: IdParam }),
body: { content: { '''application/json''': { schema: UpdateTaskSchema } } },
},
responses: {
200: { description: '''Task updated''', content: { '''application/json''': { schema: TaskSchema } } },
404: { description: '''Task not found''' },
},
});
registry.registerPath({
method: '''delete''',
path: '''/api/tasks/{id}''',
summary: '''Delete a task''',
request: { params: z.object({ id: IdParam }) },
responses: { 204: { description: '''Task deleted''' }, 404: { description: '''Task not found''' } },
});
const generator = new OpenApiGeneratorV31(registry.definitions);
export const openApiSpec = generator.generateDocument({
openapi: '''3.1.0''',
info: { title: '''Task Management API''', version: '''1.0.0''', description: '''Type-safe REST API with automatic validation and documentation''' },
servers: [{ url: '''http://localhost:3000''' }],
});
Step 5: Wire Everything Together
// src/index.ts
import express from '''express''';
import taskRoutes from '''./routes/tasks.js''';
import { openApiSpec } from '''./openapi.js''';
const app = express();
app.use(express.json());
app.get('''/openapi.json''', (_, res) => res.json(openApiSpec));
app.use('''/api/tasks''', taskRoutes);
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(500).json({ error: '''Internal server error''' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log(`OpenAPI spec: http://localhost:${PORT}/openapi.json`);
});
Testing It
npx tsx src/index.ts
Create a task:
curl -X POST http://localhost:3000/api/tasks \
-H "Content-Type: application/json" \
-d '''{"title": "Write API docs", "priority": "high", "tags": ["docs"]}'''
Hit the validation:
curl -X POST http://localhost:3000/api/tasks \
-H "Content-Type: application/json" \
-d '''{"title": "", "priority": "extreme"}'''
Response:
{
"error": "Validation failed",
"details": [
{ "path": "body.title", "message": "String must contain at least 1 character(s)", "code": "too_small" },
{ "path": "body.priority", "message": "Invalid enum value", "code": "invalid_enum_value" }
]
}
Why This Works Better Than Alternatives
vs. Joi + Swagger JSDoc: Joi schemas can'''t generate TypeScript types. You maintain types, Joi schemas, and JSDoc annotations separately. Three sources of truth that drift apart.
vs. class-validator + class-transformer: Decorator-based approaches require classes, which conflicts with functional TypeScript. Zod works with plain objects.
vs. tRPC: tRPC is excellent for TypeScript-to-TypeScript communication. But if non-TypeScript clients need your API, you need OpenAPI — and zod-to-openapi gives you that.
Going Further
Add Swagger UI:
npm install swagger-ui-express @types/swagger-ui-express
import swaggerUi from '''swagger-ui-express''';
app.use('''/docs''', swaggerUi.serve, swaggerUi.setup(openApiSpec));
Now http://localhost:3000/docs serves interactive API documentation generated entirely from your Zod schemas.
Conclusion
Zod as the single source of truth eliminates an entire class of bugs — the ones where validation accepts something the type system doesn'''t expect, or the API docs describe a field that the code ignores. Define once, validate everywhere, document automatically.
The full source is about 250 lines of actual logic. The OpenAPI spec it generates is production-ready — import it into Postman, generate client SDKs, or feed it to API gateways.
If this was helpful, you can support my work at ko-fi.com/nopkt
If this article helped you, consider buying me a coffee on Ko-fi! Follow me for more production backend patterns.
Top comments (0)