Never Trust Input — Validate at the Boundary
Validation with Zod
Zod validates user input AND infers the TS type. The standard for Node APIs.
What you'll learn
- Define a Zod schema
- Validate bodies and query params
- Return clean error responses
User input is untrusted — never assume it’s the right shape. Validate at the boundary. Zod is the standard tool in Node.
Install
npm install zod A Schema
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(1).max(60),
email: z.string().email(),
age: z.number().int().min(0).max(120).optional(),
}); Validating a Body
import express from "express";
import { z } from "zod";
const app = express();
app.use(express.json());
app.post("/api/users", (req, res) => {
const parsed = CreateUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: { code: "bad_request", issues: parsed.error.issues },
});
}
const user = parsed.data; // typed AND validated
// save user...
res.status(201).json(user);
}); safeParse returns { success: true, data } or { success: false, error }.
No throws.
A Validate Middleware
Reuse across many routes:
function validate(schema) {
return (req, res, next) => {
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: { issues: parsed.error.issues } });
}
req.validBody = parsed.data;
next();
};
}
app.post("/api/users", validate(CreateUserSchema), (req, res) => {
// req.validBody is typed
}); Coercing Query Strings
Query values are strings. Zod can coerce:
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
app.get("/api/users", (req, res) => {
const { page, limit } = QuerySchema.parse(req.query);
// page, limit are numbers
}); Deriving the Type (for TS projects)
import { z } from "zod";
const CreateUserSchema = z.object({ /* ... */ });
type CreateUser = z.infer<typeof CreateUserSchema>;
// { name: string; email: string; age?: number } One source of truth — runtime AND type-time.
Why Not Just TypeScript?
TS types are erased at runtime. An attacker sending { "age": "drop tables" }
would slip past TS but fail Zod. Always validate at the boundary.