Never trust the client. Ever. Validation is the wall between “user typed bad data” and “production database has nullable columns full of "undefined" strings”. Express gives us zero validation out of the box — we pick a library.
In simple language: validation says “is this request shaped the way I expect?” before our handler touches it. Sanitization goes further — it cleans up the input (trims whitespace, strips HTML, normalizes emails).
The three contenders
express-validator
The Express-native option. Chain validators per field, then check validationResult in the handler.
npm install express-validator
import { body, validationResult } from "express-validator";
app.post(
"/users",
body("email").isEmail().normalizeEmail(),
body("password").isLength({ min: 8 }).withMessage("8 chars min"),
body("age").optional().isInt({ min: 13, max: 120 }),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// req.body is sanitized in place
createUser(req.body);
res.status(201).end();
}
);
normalizeEmail() is sanitization — it lowercases, strips dots from Gmail, etc. That’s the “extra” express-validator brings.
Zod
The hot pick for TypeScript projects. Schemas double as TypeScript types — define once, get both runtime checking and compile-time safety.
npm install zod
import { z } from "zod";
const CreateUserSchema = z.object({
email: z.string().email().toLowerCase().trim(),
password: z.string().min(8),
age: z.number().int().min(13).max(120).optional(),
});
// type CreateUser = z.infer<typeof CreateUserSchema>;
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
req.body = result.data; // parsed + transformed
next();
};
}
app.post("/users", validate(CreateUserSchema), (req, res) => {
// req.body is now typed as CreateUser
createUser(req.body);
res.status(201).end();
});
The killer feature is z.infer — your schema IS your type. No drift between them, ever.
Joi
The veteran. Used by Hapi, still widely deployed. Same idea as Zod but predates it and has no TS inference.
npm install joi
import Joi from "joi";
const schema = Joi.object({
email: Joi.string().email().lowercase().required(),
password: Joi.string().min(8).required(),
age: Joi.number().integer().min(13).max(120),
});
app.post("/users", (req, res) => {
const { error, value } = schema.validate(req.body, { abortEarly: false });
if (error) {
return res.status(400).json({ errors: error.details });
}
createUser(value);
res.status(201).end();
});
Validating query and params, not just body
Don’t forget — query strings and URL params are user input too.
import { z } from "zod";
const ListUsersQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().trim().max(50).optional(),
});
app.get("/users", (req, res) => {
const query = ListUsersQuery.parse(req.query);
// query.page is a real number now, not "2"
res.json(listUsers(query));
});
z.coerce.number() is gold here — query strings are always strings, so we coerce "5" → 5.
Sanitization vs validation
The only difference is: validation rejects, sanitization transforms.
body("email").isEmail()→ reject if not an emailbody("email").normalizeEmail()→ lowercase, strip Gmail dotsbody("comment").trim().escape()→ strip whitespace, escape<script>tags
Don’t rely on sanitization alone for XSS — output-encode at render time. But it’s a useful belt-and-suspenders.
Which to pick?
- TypeScript project? Zod. The type inference is too good to skip.
- JavaScript-only Express app? express-validator or Joi, taste preference.
- Already using a framework that ships Joi (Hapi-style)? Stay with it.
My default for new code: Zod. The schema-as-type trick eliminates a whole category of “the DB has a string but the type says number” bugs.
Centralize it
Don’t sprinkle validation across handlers. Build a validate(schema) middleware once, then every route is one line:
app.post("/users", validate(CreateUserSchema), createUserHandler);
app.patch("/users/:id", validate(UpdateUserSchema), updateUserHandler);
Clean handlers, schemas live next to the route, no duplication.