Input Validation in TypeScript APIs: Zod vs Joi vs Class-Validator
Your API accepts a POST body. You trust it. A user sends { "age": "not a number" }. Your database query fails. Or worse, it succeeds with bad data.
Why Validate at the Boundary
TypeScript types disappear at runtime. req.body is any. Your internal functions trust typed parameters, but the data crossing your API boundary is untyped. Validate once at the edge, trust everywhere inside.
Zod (Recommended)
Schema-first. TypeScript-native. Infers types from schemas:
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(13).max(150),
role: z.enum(["user", "admin"]).default("user"),
});
type CreateUser = z.infer<typeof CreateUserSchema>;
app.post("/users", (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (\!result.success) return res.status(400).json({ errors: result.error.flatten() });
const user: CreateUser = result.data; // Fully typed\!
});
Joi (Mature, Express-Friendly)
Joi has been around longer and has richer validation rules, but does not infer TypeScript types natively. You must maintain types separately.
Class-Validator (NestJS)
Decorator-based validation on class properties. Great with NestJS, heavyweight for plain Express.
Comparison
| Feature | Zod | Joi | Class-Validator |
|---|---|---|---|
| Type inference | Yes | No | No |
| Bundle size | Small | Large | Medium |
| Framework | Any | Express | NestJS |
| Error messages | Structured | Detailed | Basic |
Validation Middleware Pattern
Create a reusable middleware factory:
function validate<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (\!result.success) return res.status(400).json({ errors: result.error.flatten() });
req.body = result.data;
next();
};
}
app.post("/users", validate(CreateUserSchema), createUser);
app.put("/users/:id", validate(UpdateUserSchema), updateUser);
Common Mistakes
- Validating inside handlers: Creates code duplication. Use middleware.
- Not validating query params: Validate path params, query strings, and headers too, not just body.
- Exposing internal validation errors: Sanitize error messages. Do not leak field names or schema structure to attackers.
- Using .parse() instead of .safeParse(): .parse() throws. In Express, uncaught throws crash the process.
Part of my Production Backend Patterns series. Follow for more practical backend engineering.
Top comments (0)