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 (1)
Running both Zod and class-validator in production across our NestJS services. Here's the honest split: class-validator wins at the HTTP request layer — the decorator pattern integrates naturally with NestJS pipes, and the error messages map cleanly to API responses. Zod wins everywhere else — env config validation, inter-service contracts, shared domain types.
The error messages alone justify Zod for config: "DB_HOST: Expected string, received undefined" at startup beats discovering a missing env var in production at 2am. We tried going all-Zod but the NestJS integration at the controller level still has friction.
The pragmatic answer in 2026: use both, draw a clear line between where each owns validation, and don't pretend one library solves every layer. Great comparison — the "validate at the boundary" principle is spot on. We just found that in a multi-service architecture, you have more boundaries than you'd expect.