express-validator
express-validator takes a different shape from Joi and Zod: instead of a standalone schema you validate manually, it's a set of middleware that plug directly into your route's handler chain and operate on the Express request. It wraps the battle-tested validator.js library and integrates tightly with req.body/req.params/req.query. This page covers validation chains, collecting results, sanitization, and custom validators.
Setup and the validation chain
npm install express-validator
import { body, validationResult } from 'express-validator'
app.post(
'/users',
// Each field is a chain of middleware — runs as part of the route:
body('email').isEmail().withMessage('valid email required'),
body('name').trim().isLength({ min: 2, max: 100 }),
body('age').optional().isInt({ min: 0, max: 150 }).toInt(),
body('role').default('user').isIn(['user', 'admin']),
// Final handler reads the collected results:
(req, res) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() })
}
createUser(req.body)
},
)Targeting different request locations
Function | Validates |
|---|---|
|
|
|
|
|
|
| Request headers |
|
|
| All of the above (any location) |
import { param, query } from 'express-validator'
app.get(
'/users/:id',
param('id').isInt().toInt(), // coerce route param
query('include').optional().isIn(['orders', 'roles']),
handler,
)Validation AND sanitization in one chain
body('email')
.trim() // sanitizer: strip surrounding whitespace
.normalizeEmail() // sanitizer: canonical form (lowercases, etc.)
.isEmail() // validator: must be a valid email
body('bio')
.trim()
.escape() // sanitizer: HTML-escape to neutralize markup
.isLength({ max: 500 })
body('age')
.toInt() // sanitizer: "42" → 42 (mutates req.body)Collecting and formatting errors
const result = validationResult(req)
if (!result.isEmpty()) {
return res.status(422).json({
error: 'Validation failed',
// Custom shape per error:
details: result.array().map((e) => ({ field: e.path, message: e.msg })),
})
}// result.array() default shape:
[
{ type: 'field', path: 'email', msg: 'valid email required', location: 'body', value: 'nope' },
{ type: 'field', path: 'name', msg: 'Invalid value', location: 'body' }
]A reusable runner middleware
// DRY: one middleware that runs after the chains and short-circuits on errors.
const handleValidation = (req, res, next) => {
const result = validationResult(req)
if (!result.isEmpty()) {
return res.status(422).json({
error: 'Validation failed',
details: result.array().map((e) => ({ field: e.path, message: e.msg })),
})
}
next()
}
app.post('/users',
body('email').isEmail().withMessage('valid email required'),
body('name').trim().notEmpty(),
handleValidation, // checks results once, here
createUserHandler, // runs only if validation passed
)Custom validators
body('email').custom(async (value) => {
const existing = await db.users.findByEmail(value)
if (existing) throw new Error('Email already in use') // → becomes the msg
return true
})
body('confirm').custom((value, { req }) => {
if (value !== req.body.password) throw new Error('Passwords must match')
return true
})Choosing among the three
Choose… | When |
|---|---|
You want validation co-located in routes & tight Express integration, plus easy async/DB checks | |
You're on TypeScript and want inferred types from one schema | |
You want a rich, framework-agnostic schema for any JS value |