Validation (zod)
Input validation in Node is the seam between "I trust this" and "I do not". Zod is the modern default — TypeScript-native schemas that validate AND infer types. Yup, Joi, and class-validator solve the same problem with different ergonomics; the discipline is the same: validate at the boundary, never trust afterwards.
Zod validation in Express, with typed inferred outputs
EXAMPLE
// npm i zod express
import express from 'express';
import { z, ZodError } from 'zod';
const app = express();
app.use(express.json({ limit: '64kb' }));
// 1) Define schemas once; derive types from them
const Money = z.object({
cents: z.number().int().nonnegative(),
currency: z.enum(['AUD', 'USD', 'NZD']).default('AUD'),
});
const CreateOrder = z.object({
customer: z.string().min(1).max(120).trim(),
total: Money,
notes: z.string().max(500).optional(),
items: z.array(z.object({
sku: z.string().regex(/^[a-z0-9-]+$/i),
qty: z.number().int().positive(),
})).min(1).max(100),
});
type CreateOrderInput = z.input<typeof CreateOrder>; // what the client sends
type CreateOrderOutput = z.output<typeof CreateOrder>; // what you USE (after transforms / defaults)
// 2) Validate via parse / safeParse
// .parse throws ZodError on failure
// .safeParse returns { success, data | error } — better for HTTP responses
app.post('/orders', (req, res) => {
const parsed = CreateOrder.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation',
issues: parsed.error.issues.map((i) => ({ path: i.path, msg: i.message })),
});
}
const data: CreateOrderOutput = parsed.data; // fully typed + transformed
// ... persist ...
res.status(201).json({ id: 'o1', ...data });
});
// 3) A reusable middleware factory keeps controllers thin
function validate<T extends z.ZodTypeAny>(schema: T, where: 'body' | 'query' | 'params' = 'body') {
return (req: any, res: any, next: any) => {
const result = schema.safeParse(req[where]);
if (!result.success) return res.status(400).json({ error: 'validation', issues: result.error.issues });
req[where] = result.data; // overwrite with parsed (defaults, transforms applied)
next();
};
}
const ListQuery = z.object({
status: z.enum(['new', 'paid', 'shipped', 'cancelled']).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string().optional(),
});
app.get('/orders', validate(ListQuery, 'query'), (req, res) => {
const q = req.query; // typed via the schema
res.json({ q });
});
// 4) Refinements + custom rules
const Signup = z.object({
email: z.string().email(),
password: z.string().min(8),
confirm: z.string().min(8),
}).refine((d) => d.password === d.confirm, {
message: 'passwords do not match',
path: ['confirm'],
});
// 5) Transforms — coerce or normalise as part of validation
const NormalEmail = z.string().email().transform((s) => s.toLowerCase().trim());
const SearchQuery = z.preprocess((v) => String(v ?? '').slice(0, 200), z.string());
// 6) Discriminated unions for polymorphic payloads
const Event = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('paid'), orderId: z.string() }),
z.object({ kind: z.literal('shipped'), orderId: z.string(), tracking: z.string() }),
]);
type Event = z.output<typeof Event>;
// 7) Composition — re-use parts across routes
const ListSchema = z.object({ cursor: z.string().optional(), limit: z.number().int().min(1).max(100).default(20) });
const OrdersQuery = ListSchema.extend({ status: z.enum(['new','paid','shipped','cancelled']).optional() });
const InvoicesQuery = ListSchema.extend({ paid: z.boolean().optional() });
// 8) Error shaping — return a consistent envelope
app.use((err: any, _req: any, res: any, _next: any) => {
if (err instanceof ZodError) return res.status(400).json({ error: 'validation', issues: err.issues });
console.error(err);
res.status(500).json({ error: 'internal' });
});
// 9) When to validate
// - request body / query / params -> ALWAYS
// - data from third-party APIs -> ALWAYS (their contract can change)
// - data read from the DB -> sometimes (when the schema is loose)
// - data crossing trust boundaries -> ALWAYS
// 10) Pitfalls
// - parse() throwing inside a handler without a catch -> 500 instead of 400
// - reusing the same schema for input + DB row (input may differ from stored shape)
// - not setting limits on req.body -> giant payload DoS
// - trusting JSON.parse output without a schema
Why it matters
Define the schema once and let `z.input` / `z.output` give you the types for free. The schema is the contract: parsing it at the boundary turns "I think the client sent a number" into a compile-time fact, and the rest of the handler is plain typed code that cannot lie to itself.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
import { z } from 'zod';
const User = z.object({ name: z.string(), age: z.number().int().min(0) });
const u = User.parse(req.body);
Try it Yourself »
Discussion
Loading…