zod
Zod validates data at API and config boundaries so invalid input never reaches business logic. One schema gives runtime checks and TypeScript types without drift.
Search across all documentation pages
Zod validates data at API and config boundaries so invalid input never reaches business logic. One schema gives runtime checks and TypeScript types without drift.
Quick-reference recipe card - copy-paste ready.
import { z } from "zod";
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(120),
role: z.enum(["member", "admin"]).default("member"),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
// Fastify route
app.post("/users", async (req, reply) => {
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ errors: parsed.error.flatten() });
}
return createUser(parsed.data);
});When to reach for this:
process.env validation at boot (see also Zod & env-schema Validation)// src/schemas/order.ts
import { z } from "zod";
export const orderItemSchema = z.object({
sku: z.string().uuid(),
quantity: z.coerce.number().int().positive(),
});
export const createOrderSchema = z.object({
customerId: z.string().uuid(),
items: z.array(orderItemSchema).min(1),
shipBy: z.string().datetime().optional(),
});
export type CreateOrderInput = z.infer<typeof createOrderSchema>;
// src/routes/orders.ts
import type { FastifyInstance } from "fastify";
import { createOrderSchema } from "../schemas/order.js";
export async function orderRoutes(app: FastifyInstance): Promise<void> {
app.post("/orders", async (req, reply) => {
const result = createOrderSchema.safeParse(req.body);
if (!result.success) {
req.log.warn({ validation: result.error.flatten() }, "invalid_order");
return reply.status(400).send({
error: "validation_failed",
details: result.error.flatten().fieldErrors,
});
}
const order = await createOrder(result.data);
return reply.status(201).send(order);
});
}// src/workers/email.worker.ts
import { z } from "zod";
const emailJobSchema = z.object({
to: z.string().email(),
templateId: z.string(),
vars: z.record(z.string()),
});
export async function processEmailJob(raw: unknown): Promise<void> {
const job = emailJobSchema.parse(raw); // throws → BullMQ retry/DLQ
await sendEmail(job);
}Key behaviors:
safeParse for HTTP - map to 400 without throwingparse for workers - let the queue retry on poison messagesz.coerce.number() for query strings and env vars.refine() for cross-field rules (e.g. shipBy must be future)| Approach | Pros | Cons |
|---|---|---|
| Zod | TS inference, composable, great DX | Runtime cost on hot paths (usually negligible) |
| JSON Schema only | Fastify native, OpenAPI export | No shared TS types without codegen |
| Manual if checks | Zero deps | Drifts from types; untestable patterns |
| class-validator (Nest) | Nest ecosystem | Decorator magic; less portable |
@fastify/type-provider-typebox or convert Zod to JSON Schema for response serialization.nestjs-zod or global pipes with Zod DTOs.import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createOrderSchema } from "./order.js";
describe("createOrderSchema", () => {
it("rejects empty items", () => {
const result = createOrderSchema.safeParse({
customerId: "550e8400-e29b-41d4-a716-446655440000",
items: [],
});
assert.equal(result.success, false);
});
});__fixtures__/invalid-orders.json.Validate at boundaries (HTTP, env, queue, external webhooks). Inside domain logic, trust typed values. Do not re-parse the same object three times per request.
Map error.flatten().fieldErrors to your API error standard. Never return ZodError stack or issues raw in production.
Use Zod as source of truth and generate OpenAPI with zod-to-openapi or maintain parallel JSON Schema for public docs. Pick one source - not both hand-edited.
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.
Reviewed by Chris St. John·Last updated Jul 19, 2026