Validating with express-validator
express-validator is a set of Express middleware that wraps the battle-tested validator.js library. Instead of hand-writing if checks at the top of every route, you declare a chain of rules per field — each chain is itself a middleware that runs before your handler. It validates and sanitizes in the same pass, collects every failure into a structured result, and integrates cleanly with the Router. Because each validator is just middleware, you compose, share, and reuse rules the same way you do any other Express building block.
Installing and the chain model
Install the package alongside Express. It ships with TypeScript types built in.
npm install express-validator
The core idea is the validation chain. A function like body('email') returns a chain object that is also a middleware. You call methods on it (.isEmail(), .trim(), .notEmpty()) to append validators and sanitizers, and the order matters — sanitizers transform the value before the next validator sees it. The chain stores its findings on the request, and a separate step reads them back.
const { body, validationResult } = require('express-validator');
app.post(
'/users',
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }),
(req, res) => {
const result = validationResult(req);
if (!result.isEmpty()) {
return res.status(400).json({ errors: result.array() });
}
res.status(201).json({ email: req.body.email });
}
);
Choosing the location: body, param, query
Each request location has its own factory function. They share the same chain API; only the source of the value differs.
| Function | Reads from | Typical use |
|---|---|---|
body('field') | req.body | JSON / form payloads |
param('id') | req.params | Route parameters like /users/:id |
query('field') | req.query | Query string ?page=2 |
header('name') | req.headers | Custom headers |
cookie('name') | req.cookies | Signed/unsigned cookies |
check('field') | all of the above | When you don’t care about location |
A route that validates a path parameter, a query value, and a body field looks like this:
const { body, param, query, validationResult } = require('express-validator');
app.patch(
'/posts/:id',
param('id').isInt({ min: 1 }).toInt(),
query('notify').optional().isBoolean().toBoolean(),
body('title').trim().notEmpty().isLength({ max: 120 }),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
res.json({ id: req.params.id, title: req.body.title, notify: req.query.notify });
}
);
Tip: Sanitizers like
.toInt()and.toBoolean()mutate the parsed value in place. After the chain runs,req.params.idis a realnumber, not the original string — so downstream code and your database driver receive correct types.
Chaining sanitizers and validators
Validators assert a condition (.isEmail(), .isLength()); sanitizers reshape the value (.trim(), .escape(), .toLowerCase()). Place sanitizers before the validators that depend on them. .optional() short-circuits the chain when the field is absent, and .withMessage() attaches a human-readable error to the validator immediately before it.
body('username')
.trim() // sanitize first
.toLowerCase()
.notEmpty().withMessage('Username is required')
.isLength({ min: 3, max: 20 }).withMessage('Must be 3-20 characters')
.matches(/^[a-z0-9_]+$/).withMessage('Only lowercase letters, numbers, _');
For rules the library doesn’t ship, .custom() accepts a predicate that returns truthy/falsy or throws, and can be async for database lookups.
body('email')
.isEmail().normalizeEmail()
.custom(async (value) => {
const existing = await db.findUserByEmail(value);
if (existing) throw new Error('Email already in use');
return true;
});
Reading validationResult
validationResult(req) returns a Result object that aggregates every failure across all chains on the request. Its key methods:
isEmpty()—truewhen nothing failed.array()— a flat list of error objects.mapped()— keyed by field, useful for form rendering.throw()— throws if there are errors, handy with an async error handler.
A failing request produces structured output:
const result = validationResult(req);
console.log(result.array());
Output:
[
{ type: 'field', value: 'bob', msg: 'Must be 3-20 characters', path: 'username', location: 'body' },
{ type: 'field', value: 'no', msg: 'Invalid value', path: 'email', location: 'body' }
]
A reusable validation-handler middleware
Repeating the validationResult check in every handler is noise. Extract it into one middleware placed after the chains. It inspects the accumulated result and either short-circuits with a 422 or calls next() to reach the real handler.
// validate.js
const { validationResult } = require('express-validator');
const validate = (req, res, next) => {
const errors = validationResult(req);
if (errors.isEmpty()) return next();
res.status(422).json({
errors: errors.array().map((e) => ({ field: e.path, message: e.msg })),
});
};
module.exports = validate;
Group the rules for a route into an array, then append validate as the last middleware before your handler. This keeps rules declarative and reusable across routes.
const express = require('express');
const { body } = require('express-validator');
const validate = require('./validate');
const router = express.Router();
const createUserRules = [
body('email').trim().isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }).withMessage('Password too short'),
body('age').optional().isInt({ min: 13 }).toInt(),
];
router.post('/users', createUserRules, validate, async (req, res) => {
const user = await db.createUser(req.body);
res.status(201).json(user);
});
module.exports = router;
Warning: The validation chain only writes to the request when its middleware actually runs. If you forget to register the chains (or place
validatebefore them),validationResultreturns an empty result and every request passes — a silent security hole. Always order rules first, thenvalidate.
Best Practices
- Order each chain as sanitizers first, then validators, so checks see the cleaned value.
- Centralize the result check in one
validatemiddleware instead of repeatingvalidationResultin handlers. - Define rules as named arrays (
createUserRules) so they can be shared between create/update routes. - Always sanitize string inputs with
.trim(), and coerce types with.toInt()/.toBoolean()so downstream code gets real types. - Use
.optional()for fields that may be absent rather than making every field required. - Return
422 Unprocessable Entity(or400) with a stable error shape so clients can render field-level messages reliably. - Reach for
.custom()withasyncfor uniqueness and cross-field rules instead of duplicating logic in the handler.