Episode 3 — NodeJS MongoDB Backend Architecture / 3.10 — Input Validation
3.10.b — Express Validator
express-validator is the most popular validation library for Express.js, providing a chainable API for field-level validation, sanitization, and schema-based validation with built-in middleware support.
< 3.10.a — Why Validation Matters | 3.10.c — Zod Validation >
1. Installing and Setup
npm install express-validator
const express = require('express');
const { body, param, query, validationResult } = require('express-validator');
const app = express();
app.use(express.json());
express-validator works as Express middleware. Each validator runs in the request pipeline and attaches results to the request object.
2. Field-Level Validators: body(), param(), query()
Each function targets a specific part of the HTTP request:
| Function | Targets | Example Use |
|---|---|---|
body('field') | req.body.field | POST/PUT data |
param('field') | req.params.field | URL parameters like /users/:id |
query('field') | req.query.field | Query strings like ?page=1 |
header('field') | req.headers.field | HTTP headers |
cookie('field') | req.cookies.field | Cookies |
const { body, param, query } = require('express-validator');
// Validate request body fields
app.post('/users',
body('email').isEmail(),
body('name').isString().isLength({ min: 2, max: 50 }),
handleValidation
);
// Validate URL parameters
app.get('/users/:id',
param('id').isMongoId().withMessage('Invalid user ID format'),
handleValidation
);
// Validate query strings
app.get('/products',
query('page').optional().isInt({ min: 1 }).toInt(),
query('limit').optional().isInt({ min: 1, max: 100 }).toInt(),
query('sort').optional().isIn(['price', 'name', 'date']),
handleValidation
);
3. Chaining Validators
Validators can be chained to apply multiple rules to a single field:
app.post('/register',
body('email')
.notEmpty().withMessage('Email is required')
.isEmail().withMessage('Must be a valid email')
.normalizeEmail(),
body('password')
.notEmpty().withMessage('Password is required')
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
.matches(/[A-Z]/).withMessage('Must contain an uppercase letter')
.matches(/[0-9]/).withMessage('Must contain a number')
.matches(/[!@#$%^&*]/).withMessage('Must contain a special character'),
body('username')
.notEmpty().withMessage('Username is required')
.isAlphanumeric().withMessage('Username must be alphanumeric')
.isLength({ min: 3, max: 20 }).withMessage('Username must be 3-20 characters')
.trim()
.toLowerCase(),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, errors: errors.array() });
}
// All fields are valid — proceed
}
);
4. Built-in Validators Reference
| Validator | Description | Example |
|---|---|---|
isEmail() | Valid email format | body('email').isEmail() |
isLength({ min, max }) | String length range | body('name').isLength({ min: 2, max: 50 }) |
isNumeric() | Numeric string | body('phone').isNumeric() |
isInt({ min, max }) | Integer within range | body('age').isInt({ min: 0, max: 150 }) |
isFloat({ min, max }) | Float within range | body('price').isFloat({ min: 0 }) |
isURL() | Valid URL | body('website').isURL() |
isStrongPassword() | Min 8 chars, 1 lower, 1 upper, 1 number, 1 symbol | body('password').isStrongPassword() |
isIn(values) | Value in allowed list | body('role').isIn(['user', 'admin']) |
isMongoId() | Valid MongoDB ObjectId | param('id').isMongoId() |
isBoolean() | Boolean value | body('active').isBoolean() |
isDate() | Valid date string | body('birthDate').isDate() |
isJSON() | Valid JSON string | body('metadata').isJSON() |
isAlpha() | Only letters | body('firstName').isAlpha() |
isAlphanumeric() | Only letters and numbers | body('username').isAlphanumeric() |
matches(regex) | Matches regular expression | body('code').matches(/^[A-Z]{3}-\d{4}$/) |
isUUID() | Valid UUID | body('token').isUUID() |
isMobilePhone() | Valid phone number | body('phone').isMobilePhone('en-IN') |
isStrongPassword Options
body('password').isStrongPassword({
minLength: 8,
minLowercase: 1,
minUppercase: 1,
minNumbers: 1,
minSymbols: 1,
});
// Rejects: "password", "Password1", "Pass1!" (too short)
// Accepts: "MyP@ssw0rd"
5. Custom Validators
When built-in validators are not enough, use .custom():
const User = require('../models/User');
app.post('/register',
// Check if email already exists in database
body('email').isEmail().custom(async (value) => {
const existingUser = await User.findOne({ email: value });
if (existingUser) {
throw new Error('Email already registered');
}
return true;
}),
// Confirm password matches
body('confirmPassword').custom((value, { req }) => {
if (value !== req.body.password) {
throw new Error('Passwords do not match');
}
return true;
}),
// Validate date is in the past
body('birthDate').isDate().custom((value) => {
if (new Date(value) >= new Date()) {
throw new Error('Birth date must be in the past');
}
return true;
}),
// Validate array of tags
body('tags').optional().isArray({ max: 10 }).custom((tags) => {
if (tags.some(tag => typeof tag !== 'string' || tag.length > 30)) {
throw new Error('Each tag must be a string with max 30 characters');
}
return true;
}),
);
6. Extracting Errors with validationResult
const { validationResult } = require('express-validator');
app.post('/users',
/* ...validators... */
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
// errors.array() returns:
// [
// { type: 'field', msg: 'Must be a valid email', path: 'email',
// location: 'body', value: 'not-email' },
// { type: 'field', msg: 'Password must be at least 8 characters',
// path: 'password', location: 'body', value: '123' }
// ]
return res.status(400).json({
success: false,
errors: errors.array(),
});
}
// Only valid fields — extract them
const { email, password, name } = req.body;
}
);
Formatting Errors
// Only first error per field
const errors = validationResult(req).formatWith(({ msg, path }) => ({
field: path,
message: msg,
}));
// Returns: [{ field: 'email', message: 'Must be a valid email' }]
// Mapped by field name
const mapped = validationResult(req).mapped();
// Returns: { email: { msg: '...', path: 'email', ... }, password: { ... } }
7. Reusable Validation Middleware
Instead of repeating validation logic, create reusable middleware functions:
// middleware/validate.js
const { validationResult } = require('express-validator');
const validate = (validations) => {
return async (req, res, next) => {
// Run all validations
await Promise.all(validations.map(v => v.run(req)));
const errors = validationResult(req);
if (errors.isEmpty()) {
return next();
}
return res.status(400).json({
success: false,
errors: errors.array().map(err => ({
field: err.path,
message: err.msg,
})),
});
};
};
module.exports = validate;
// validators/userValidators.js
const { body, param } = require('express-validator');
const registerValidation = [
body('email').isEmail().withMessage('Valid email required').normalizeEmail(),
body('password').isStrongPassword().withMessage('Password too weak'),
body('name').trim().isLength({ min: 2, max: 50 }).withMessage('Name: 2-50 chars'),
body('age').optional().isInt({ min: 13, max: 120 }),
];
const loginValidation = [
body('email').isEmail().withMessage('Valid email required').normalizeEmail(),
body('password').notEmpty().withMessage('Password is required'),
];
const updateProfileValidation = [
param('id').isMongoId().withMessage('Invalid user ID'),
body('name').optional().trim().isLength({ min: 2, max: 50 }),
body('bio').optional().trim().isLength({ max: 500 }),
body('website').optional().isURL(),
];
module.exports = { registerValidation, loginValidation, updateProfileValidation };
// routes/userRoutes.js
const validate = require('../middleware/validate');
const { registerValidation, loginValidation, updateProfileValidation } = require('../validators/userValidators');
router.post('/register', validate(registerValidation), userController.register);
router.post('/login', validate(loginValidation), userController.login);
router.put('/profile/:id', validate(updateProfileValidation), userController.updateProfile);
8. Validation Schemas with checkSchema()
For complex validation, checkSchema() offers a declarative approach:
const { checkSchema } = require('express-validator');
const registrationSchema = checkSchema({
email: {
in: ['body'],
isEmail: { errorMessage: 'Must be a valid email' },
normalizeEmail: true,
custom: {
options: async (value) => {
const exists = await User.findOne({ email: value });
if (exists) throw new Error('Email already in use');
},
},
},
password: {
in: ['body'],
isLength: {
options: { min: 8, max: 128 },
errorMessage: 'Password must be 8-128 characters',
},
isStrongPassword: {
options: { minUppercase: 1, minNumbers: 1, minSymbols: 1 },
errorMessage: 'Must include uppercase, number, and symbol',
},
},
username: {
in: ['body'],
trim: true,
isAlphanumeric: { errorMessage: 'Username must be alphanumeric' },
isLength: {
options: { min: 3, max: 20 },
errorMessage: 'Username must be 3-20 characters',
},
toLowerCase: true,
},
age: {
in: ['body'],
optional: true,
isInt: {
options: { min: 13, max: 120 },
errorMessage: 'Age must be 13-120',
},
toInt: true,
},
});
// Usage
router.post('/register', registrationSchema, validate, controller.register);
9. Real Examples
Complete Registration Endpoint
const express = require('express');
const { body, validationResult } = require('express-validator');
const bcrypt = require('bcrypt');
const User = require('../models/User');
const router = express.Router();
router.post('/register',
body('email')
.notEmpty().withMessage('Email is required')
.isEmail().withMessage('Invalid email format')
.normalizeEmail()
.custom(async (email) => {
const user = await User.findOne({ email });
if (user) throw new Error('Email already registered');
}),
body('password')
.notEmpty().withMessage('Password is required')
.isStrongPassword({
minLength: 8, minUppercase: 1, minNumbers: 1, minSymbols: 1,
}).withMessage('Password must be 8+ chars with uppercase, number, and symbol'),
body('confirmPassword')
.custom((value, { req }) => {
if (value !== req.body.password) throw new Error('Passwords do not match');
return true;
}),
body('name')
.trim()
.notEmpty().withMessage('Name is required')
.isLength({ min: 2, max: 50 }).withMessage('Name must be 2-50 characters')
.matches(/^[a-zA-Z\s]+$/).withMessage('Name can only contain letters and spaces'),
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array().map(e => ({ field: e.path, message: e.msg })),
});
}
const { email, password, name } = req.body;
const hashedPassword = await bcrypt.hash(password, 12);
const user = await User.create({ email, password: hashedPassword, name });
res.status(201).json({
success: true,
data: { id: user._id, email: user.email, name: user.name },
});
}
);
Product Search with Query Validation
router.get('/products',
query('page').optional().isInt({ min: 1 }).toInt().default(1),
query('limit').optional().isInt({ min: 1, max: 100 }).toInt().default(20),
query('sort').optional().isIn(['price', '-price', 'name', '-name', 'createdAt']),
query('minPrice').optional().isFloat({ min: 0 }).toFloat(),
query('maxPrice').optional().isFloat({ min: 0 }).toFloat(),
query('category').optional().isMongoId(),
query('search').optional().trim().isLength({ max: 200 }).escape(),
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, errors: errors.array() });
}
const { page, limit, sort, minPrice, maxPrice, category, search } = req.query;
const filter = {};
if (minPrice || maxPrice) {
filter.price = {};
if (minPrice) filter.price.$gte = minPrice;
if (maxPrice) filter.price.$lte = maxPrice;
}
if (category) filter.category = category;
if (search) filter.name = { $regex: search, $options: 'i' };
const products = await Product.find(filter)
.sort(sort || '-createdAt')
.skip((page - 1) * limit)
.limit(limit);
res.json({ success: true, data: products, page, limit });
}
);
Key Takeaways
body(),param(),query()target different parts of the request- Chain validators for multiple rules on one field:
.isEmail().normalizeEmail() .custom()handles async checks like database uniquenessvalidationResult(req)extracts all validation errors from the request- Reusable middleware keeps routes clean — separate validators from controllers
checkSchema()provides a declarative, object-based validation definition- Always validate AND sanitize —
.trim(),.escape(),.normalizeEmail(),.toInt()
Explain-It Challenge
You are building an e-commerce API endpoint
PUT /products/:idthat acceptsname,price,description,category(must be one of a predefined list),tags(array of strings), andstock(non-negative integer). Write the complete express-validator validation chain, a reusable validation middleware, and the route handler. Include custom validators for business rules like "price cannot be reduced by more than 50% in a single update."