Building a Validation Layer with Zod
While building any backend API, you can never fully trust the data coming in from a client, a third-party webhook, or even a frontend application you wrote yourself. Every robust API needs a strict validation layer to act as a bouncer, ensuring that incoming payloads match the exact shape your business logic expects. Even if we are writing typescript, we are not fully covered, coz, of course, typescript does not exist on runtime. When your Node server is running, it is executing plain JavaScript. All those interfaces and types are stripped away during the build step.
Historically, solving this meant writing massive, ugly manual validation blocks full of if statements to check typeof for every single field. This clutters your business logic and This is where Zod comes in.
Zod: Your Single Source of Truth
Zod is a TypeScript-first schema declaration and validation library. It acts as the missing bridge between your runtime data and your compile time types. Instead of writing a TypeScript interface and a separate validation function, you define a Zod schema once.
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(2, "Name is too short").max(50),
email: z.string().email("Invalid email"),
age: z.number().int().positive(),
preferences: z.object({
theme: z.enum(["light", "dark"]).default("light")
}).optional()
});
type UserPayload = z.infer<typeof userSchema>;
Implementing Zod in Express
When raw JSON hits your Express server, you need to pass it through this schema. Zod gives you two primary ways to do this:
.parse(data): Checks the data and throws an exception if it’s invalid..safeParse(data): Checks the data and returns an object telling you if it worked or failed, without throwing errors.
Here is the pattern I highly recommend, a simple, reusable Express middleware that validates the body before it ever reaches your route handler -
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject } from 'zod';
const validateBody = (schema: AnyZodObject) => {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Validation failed",
details: result.error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}))
});
}
req.body = result.data;
next();
};
};
Now, the actual route handler becomes incredibly clean.
app.post('/users', validateBody(userSchema), (req, res) => {
const userData = req.body;
res.status(201).json({ success: true });
});