Zod at Boundaries
TypeScript cannot validate HTTP bodies, query strings, or environment variables at runtime - Zod schemas at system boundaries turn unknown input into typed, trusted data.
Search across all documentation pages
TypeScript cannot validate HTTP bodies, query strings, or environment variables at runtime - Zod schemas at system boundaries turn unknown input into typed, trusted data.
import { z } from 'zod';
const EnvSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
});
export const env = EnvSchema.parse(process.env);const CreateUser = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
});
type CreateUser = z.infer<typeof CreateUser>;When to reach for this:
process.envas MyType casts on req.bodyimport { z } from 'zod';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
const EnvSchema = z.object({
PORT: z.coerce.number().default(3000),
});
const env = EnvSchema.parse(process.env);
const CreateItem = z.object({
name: z.string().min(1),
qty: z.number().int().positive(),
});
async function readJson(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
const server = createServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/items') {
try {
const raw = await readJson(req);
const item = CreateItem.parse(raw);
sendJson(res, 201, { id: '1', ...item });
} catch (err) {
if (err instanceof z.ZodError) {
sendJson(res, 400, { error: 'validation_failed', issues: err.flatten() });
return;
}
throw err;
}
return;
}
sendJson(res, 404, { error: 'not_found' });
});
server.listen(env.PORT);What this demonstrates:
parse throws ZodError on failure - map to 400, not 500z.coerce.number() handles string env vars like "3000"z.infer derives TypeScript types from the same schema as runtimeflatten() produces field-level errors safe for clientsparse runs synchronously - keep schemas modest on hot paths or cache compiled schemas.safeParse returns { success, data | error } - functional style without try/catch.z.string().transform(s => s.trim())) run after validation - document side effects..refine()) express cross-field rules - password confirmation, date ranges.| Boundary | Schema location | Failure mode |
|---|---|---|
process.env | config/env.ts at import | process.exit(1) |
| HTTP body | Route handler / middleware | 400 JSON |
| Outbound SDK | Optional - trust vendor types | Log + circuit break |
import { z } from 'zod';
const IdParams = z.object({ id: z.string().uuid() });
// Reusable middleware pattern
function parseParams<T extends z.ZodType>(schema: T, input: unknown): z.infer<T> {
return schema.parse(input);
}.parse on huge payloads repeatedly - compile once, reuse schema objects. Fix: module-level const Schema = z.object(...).flatten() or custom error map. Fix: log full error server-side only.z.infer only, no parallel interface.undefined fails strict schemas. Fix: .optional(), .default(), or document required vars.z.string() for HTML/JSON columns - validation != sanitization. Fix: escape on output, parameterized SQL.| Alternative | Use When | Don't Use When |
|---|---|---|
| Fastify JSON Schema | Fastify-native perf | Zod ecosystem already standard |
| class-validator (NestJS) | Decorator DTOs in NestJS 11 | Minimal Express service |
| Valibot | Smaller bundle size | Team already standardized on Zod |
| Manual guards | Tiny internal scripts | Any external HTTP input |
Types erase at compile time. Attackers send malformed JSON - only runtime validation protects you.
parse throws - good with try/catch at HTTP layer. safeParse for batch validation without exceptions.
z.object({ page: z.coerce.number().default(1) }).parse(req.query) - coerce strings to numbers.
Yes - use ConfigModule with custom Zod factory or validate in main.ts before NestFactory.create.
CreateUser.partial() or .pick({ name: true }) for patch DTOs.
Publish schemas from @acme/validation - frontend uses same package for client-side pre-validation.
zod-to-openapi generates spec from schemas - single source for docs and validation.
Zod is fast enough for most APIs - profile before micro-optimizing; cache parsed env at boot.
z.discriminatedUnion('type', [...]) for tagged unions - good for webhook event types.
Advanced cross-field validation with custom issue paths - use sparingly for clarity.
Yes at repository boundary if driver returns unknown - do not trust ORM types alone for external columns.
expect(() => Schema.parse(bad)).toThrow(z.ZodError) in node:test - table-driven invalid fixtures.
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 16, 2026