This article is published in English.
Zod vs express-validator: Two Approaches to Express Validation
Compares schema-first request validation with Zod against chain-based express-validator middleware, covering setup, error formatting, and common pitfalls.
Handling untrusted input is one of the first problems any Express API must solve, and there is more than one way to do it — from schema-first libraries to more procedural, chain-based validators. This article looks at both approaches, starting with a schema-driven method built on Zod.
Validating requests with Zod
Express performs no validation on incoming data by itself. Without a check at the boundary, route handlers receive raw req.body, req.query, and req.params as-is — numeric fields that are actually strings, fields that are missing entirely, and payloads whose shape only causes trouble once they reach your business logic.
Zod addresses this by letting you describe expected data shapes as TypeScript-first schemas. You define a schema once, derive a static type from it with z.infer, and parse incoming data at the edge of your HTTP layer so everything downstream only ever sees valid data. Anything that fails validation can become an HTTP 400 response before your handler code runs.
The examples below use Zod 4 (z.email(), z.uuid(), z.coerce), plus an Express validation middleware, a shared error-formatting helper, and a list of pitfalls.
Prerequisites
You'll need Node.js version 26, Zod 4 (npm i zod), and Express with its type definitions (npm i express and npm i -D @types/express). The older Zod 3 chained syntax, such as z.string().email(), still works in v4 but is deprecated — prefer the newer top-level functions shown below.
Declaring schemas
// schemas.ts
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150).optional()
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export const userIdParamSchema = z.object({
id: z.uuid()
});
export const listUsersQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(10),
q: z.string().trim().min(1).optional()
});
z.coerce.number() is useful for query string values, since anything read from an HTTP query arrives as a string regardless of its logical type. Favor safeParse over parse at the boundary so you keep control of the resulting HTTP status and response body.
Formatting errors consistently
Convert ZodError.issues into one stable JSON structure rather than formatting failures separately in each route. Zod 4 also provides z.flattenError() for a flat, field-keyed error map, and z.treeifyError() for a nested shape that mirrors the schema.
// format-zod-error.ts
import { ZodError } from 'zod';
export function formatZodError(error: ZodError) {
return {
message: 'Validation failed',
issues: error.issues.map((issue) => ({
path: issue.path.join('.') || '(root)',
message: issue.message,
code: issue.code
}))
};
}
Validation middleware
Validate body, query, and params before the route handler runs, then write the parsed values back so the handler receives typed, coerced data.
// validate.ts
import { NextFunction, Request, Response } from 'express';
import { ZodType } from 'zod';
import { formatZodError } from './format-zod-error';
type RequestSchemas = {
body?: ZodType;
query?: ZodType;
params?: ZodType;
};
export function validate(schemas: RequestSchemas) {
return (req: Request, res: Response, next: NextFunction) => {
const parseOrReject = (schema: ZodType, value: unknown) => {
const parsed = schema.safeParse(value);
if (!parsed.success) {
res.status(400).json(formatZodError(parsed.error));
return null;
}
return parsed.data;
};
if (schemas.body) {
const body = parseOrReject(schemas.body, req.body);
if (body === null) return;
req.body = body;
}
if (schemas.query) {
const query = parseOrReject(schemas.query, req.query);
if (query === null) return;
res.locals.query = query;
}
if (schemas.params) {
const params = parseOrReject(schemas.params, req.params);
if (params === null) return;
res.locals.params = params;
}
next();
};
}
Wire it up per route like this:
app.post('/users', validate({ body: createUserSchema }), (req, res) => {
// req.body is CreateUserInput
res.status(201).json({ id: crypto.randomUUID(), ...req.body });
});
app.get('/users', validate({ query: listUsersQuerySchema }), (req, res) => {
const { limit, q } = res.locals.query;
// ...
});
app.get('/users/:id', validate({ params: userIdParamSchema }), (req, res) => {
const { id } = res.locals.params;
// ...
});
Query and params results are stored on res.locals because Express's types treat req.query/req.params as plain string maps; replacing them directly would conflict with that typing.
Pitfalls
- Query strings are always strings — use
z.coerce(orz.string()plus a transform) for numbers and booleans. parsethrows a rawZodError; either catch and map it to a 400 yourself, or usesafeParseinstead.- Zod object schemas strip unknown keys by default; add
.strict()to reject them. - Inferred types like
CreateUserInputexist only at compile time — always parse at the boundary too. - In Zod 4,
z.uuid()checks against the newer, tighter UUID specification; if you just need a generic pattern of eight, four, four, four, and twelve hex digits without the stricter rules, reach forz.guid()instead.
An Alternative: Middleware-Based Validation with express-validator
Zod is not the only way to keep bad input out of your handlers. Express applications have long relied on express-validator, a library built specifically as Express middleware, and it takes a different approach to the same problem.
Picture a registration request like this one:
{
"email": "hello",
"password": "123"
}
If a controller inspects this payload directly, every field needs its own manual check, which quickly turns into a wall of conditionals mixing validation with business logic:
if (!email) ...
if (!email.includes("@")) ...
if (!password) ...
if (password.length < 8) ...
express-validator moves that logic out of the controller and into a dedicated middleware step, so the request flows through validation before it ever reaches your handler:
Request
↓
Validation
↓
Controller
↓
Business Logic
That separation is the whole point of the library: your controller is left free to do only what it's meant to do.
To get started, install the package:
npm install express-validator
Import the body helper and build a validation chain for each field you care about:
import { body } from "express-validator";
export const registerValidator = [
body("email")
.isEmail()
.withMessage("Invalid email"), body("password")
.isLength({ min: 8 })
.withMessage("Password must contain at least 8 characters"), body("username")
.notEmpty()
.withMessage("Username is required"),
];
Attach that middleware to the route, ahead of the controller:
router.post(
"/register",
registerValidator,
registerController
);
Defining the checks isn't enough on its own — you still need to read out whatever errors were collected during validation:
import { validationResult } from "express-validator";
const errors = validationResult(req);if (!errors.isEmpty()) {
return res.status(400).json({
errors: errors.array(),
});
}
With that check in place, invalid payloads get rejected with a 400 before any business logic runs.
Built-in validators cover the common cases well:
.isEmail()
.isLength()
.notEmpty()
.isInt()
But real applications often need rules the library can't know in advance — for instance, during registration you might need to ask whether an email is already taken. That's what .custom() is for:
body("email")
.isEmail()
.bail()
.custom(async (email) => {
const user = await User.findOne({ email });
if (user) {
throw new Error("Email already registered");
} return true;
});
Custom validators can be asynchronous, which makes them suitable for database lookups and other checks that depend on your own domain logic. Note the .bail() call before the custom check — it skips the rest of the chain, including the async lookup, if the email already failed the .isEmail() check, avoiding a pointless database round trip.
Choosing between the two libraries — or Joi, another established option — comes down to what fits your stack: express-validator suits projects already built around Express middleware, Zod suits TypeScript-first, schema-driven codebases, and Joi is a mature general-purpose alternative. There's no universally correct pick; it depends on your application's architecture.
Whichever tool you choose, express-validator's strengths are its built-in validators, sanitization helpers, custom and async validators, its middleware model, and centralized error handling. A clean Express request pipeline generally looks like this:
Request
↓
Validator
↓
Controller
↓
Service
↓
Database
The point is never just confirming that a string resembles an email address — it's rejecting bad input as early as possible so the rest of the application can stay clean.