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.
Recipe
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:
- Bootstrapping config from
process.env - POST/PUT JSON body validation in Express 5 or Fastify 5
- Parsing webhook payloads from Stripe, Twilio, etc.
- Replacing unsafe
as MyTypecasts onreq.body
Working Example
import { 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:
parsethrowsZodErroron failure - map to 400, not 500z.coerce.number()handles string env vars like"3000"z.inferderives TypeScript types from the same schema as runtimeflatten()produces field-level errors safe for clients
Deep Dive
How It Works
- Zod builds a validator chain;
parseruns synchronously - keep schemas modest on hot paths or cache compiled schemas. safeParsereturns{ success, data | error }- functional style without try/catch.- Transforms (
z.string().transform(s => s.trim())) run after validation - document side effects. - Refinements (
.refine()) express cross-field rules - password confirmation, date ranges.
Boundary Map
| 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 |
TypeScript Notes
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);
}Gotchas
- Using
.parseon huge payloads repeatedly - compile once, reuse schema objects. Fix: module-levelconst Schema = z.object(...). - Leaking ZodError stacks to clients - use
flatten()or custom error map. Fix: log full error server-side only. - Duplicating schema and interface - drift guaranteed. Fix:
z.inferonly, no parallelinterface. - Optional env vars without defaults -
undefinedfails strict schemas. Fix:.optional(),.default(), or document required vars. - Trusting
z.string()for HTML/JSON columns - validation != sanitization. Fix: escape on output, parameterized SQL.
Alternatives
| 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 |
FAQs
Why not TypeScript alone?
Types erase at compile time. Attackers send malformed JSON - only runtime validation protects you.
parse vs safeParse?
parse throws - good with try/catch at HTTP layer. safeParse for batch validation without exceptions.
How do I validate query strings?
z.object({ page: z.coerce.number().default(1) }).parse(req.query) - coerce strings to numbers.
Can Zod validate env in NestJS?
Yes - use ConfigModule with custom Zod factory or validate in main.ts before NestFactory.create.
What about partial updates PATCH?
CreateUser.partial() or .pick({ name: true }) for patch DTOs.
How do I share Zod schemas with frontend?
Publish schemas from @acme/validation - frontend uses same package for client-side pre-validation.
Does Zod work with OpenAPI?
zod-to-openapi generates spec from schemas - single source for docs and validation.
Performance on hot paths?
Zod is fast enough for most APIs - profile before micro-optimizing; cache parsed env at boot.
How to handle z.union?
z.discriminatedUnion('type', [...]) for tagged unions - good for webhook event types.
What is superRefine?
Advanced cross-field validation with custom issue paths - use sparingly for clarity.
Should database rows be Zod parsed?
Yes at repository boundary if driver returns unknown - do not trust ORM types alone for external columns.
How do I test schemas?
expect(() => Schema.parse(bad)).toThrow(z.ZodError) in node:test - table-driven invalid fixtures.
Related
- Typing Express/Fastify Handlers - handler generics
- Configuration Basics - env loading patterns
- Zod - library-focused recipes
- Error Response Standards - 400 shape
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.