Zod in Production: Runtime Type Safety Without the Tax
TypeScript gives you confidence at compile time. But your API receives strings and JSON — the types are gone by the time a request lands. Zod validates and coerces at the request boundary so malformed data never reaches your business logic.
Write the Schema First
The key pattern: write the Zod schema first, derive the TypeScript type from it with z.infer. One definition, zero drift between the runtime validator and the compile-time type.
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['admin', 'user']).default('user'),
age: z.number().int().min(18).optional(),
});
type CreateUser = z.infer<typeof CreateUserSchema>;
// ^-- { email: string; name: string; role: "admin" | "user"; age?: number }One schema. The type is derived from it automatically. You cannot have a mismatch.
Middleware, Not Handlers
Wrap validation in middleware instead of calling .parse() in every handler. Use safeParse — not parse — in async contexts, since parse throws and async route handlers won't always catch it through your error middleware.
function validate<T>(schema: z.ZodSchema<T>) {
return async (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
errors: result.error.flatten().fieldErrors,
});
}
req.body = result.data; // coerced and typed
next();
};
}
router.post('/users', validate(CreateUserSchema), createUserHandler);Now every handler downstream of that middleware receives validated, coerced data. No if (!email) guards, no string-to-number conversions scattered across the codebase.
The Overhead Question
Parse overhead at our scale: 0.4μs for small payloads, 6.8μs for large ones. Our request p99 latency is 380ms. Zod adds less than 2%. It is not a performance question.
What it is is a correctness question. We caught 340 malformed requests in the first week after adding Zod middleware to a service that had been running for two years. They were all silently succeeding before — doing the wrong thing with garbage data.
What Not to Validate
Don't validate internal function calls, database results you wrote the schema for, or anything that's already been validated earlier in the request lifecycle. Validate at system boundaries: incoming HTTP requests, webhook payloads, external API responses. Nowhere else.