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:

FunctionTargetsExample Use
body('field')req.body.fieldPOST/PUT data
param('field')req.params.fieldURL parameters like /users/:id
query('field')req.query.fieldQuery strings like ?page=1
header('field')req.headers.fieldHTTP headers
cookie('field')req.cookies.fieldCookies
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

ValidatorDescriptionExample
isEmail()Valid email formatbody('email').isEmail()
isLength({ min, max })String length rangebody('name').isLength({ min: 2, max: 50 })
isNumeric()Numeric stringbody('phone').isNumeric()
isInt({ min, max })Integer within rangebody('age').isInt({ min: 0, max: 150 })
isFloat({ min, max })Float within rangebody('price').isFloat({ min: 0 })
isURL()Valid URLbody('website').isURL()
isStrongPassword()Min 8 chars, 1 lower, 1 upper, 1 number, 1 symbolbody('password').isStrongPassword()
isIn(values)Value in allowed listbody('role').isIn(['user', 'admin'])
isMongoId()Valid MongoDB ObjectIdparam('id').isMongoId()
isBoolean()Boolean valuebody('active').isBoolean()
isDate()Valid date stringbody('birthDate').isDate()
isJSON()Valid JSON stringbody('metadata').isJSON()
isAlpha()Only lettersbody('firstName').isAlpha()
isAlphanumeric()Only letters and numbersbody('username').isAlphanumeric()
matches(regex)Matches regular expressionbody('code').matches(/^[A-Z]{3}-\d{4}$/)
isUUID()Valid UUIDbody('token').isUUID()
isMobilePhone()Valid phone numberbody('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

  1. body(), param(), query() target different parts of the request
  2. Chain validators for multiple rules on one field: .isEmail().normalizeEmail()
  3. .custom() handles async checks like database uniqueness
  4. validationResult(req) extracts all validation errors from the request
  5. Reusable middleware keeps routes clean — separate validators from controllers
  6. checkSchema() provides a declarative, object-based validation definition
  7. Always validate AND sanitize.trim(), .escape(), .normalizeEmail(), .toInt()

Explain-It Challenge

You are building an e-commerce API endpoint PUT /products/:id that accepts name, price, description, category (must be one of a predefined list), tags (array of strings), and stock (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."