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.
Recipe
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:
- Every public HTTP body, query, and params surface
process.envvalidation at boot (see also Zod & env-schema Validation)- BullMQ job payloads before handler execution
- Webhook signature payloads from Stripe, GitHub, etc.
Working Example
// 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:
safeParsefor HTTP - map to 400 without throwingparsefor workers - let the queue retry on poison messagesz.coerce.number()for query strings and env vars.refine()for cross-field rules (e.g.shipBymust be future)
Comparison
| 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 teams often use Zod +
@fastify/type-provider-typeboxor convert Zod to JSON Schema for response serialization. - NestJS teams may use
nestjs-zodor global pipes with Zod DTOs.
Testing
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);
});
});- Test schemas directly - faster than HTTP integration tests for edge cases.
- Keep golden invalid fixtures in
__fixtures__/invalid-orders.json.
FAQs
Zod at every layer or only controllers?
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.
How do I avoid leaking Zod internals in API errors?
Map error.flatten().fieldErrors to your API error standard. Never return ZodError stack or issues raw in production.
Zod vs JSON Schema for OpenAPI?
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.
Related
- Zod & env-schema Validation - boot-time env parsing
- JSON Schema Validation - Fastify-native alternative
- Error Response Standards - 400 shape
- Essential Libraries Basics - when Zod is the default
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.