Input Validation with Express-validator

Building secure and reliable Express applications through input validation

The Importance of Input Validation

Input validation is one of the most critical aspects of building secure and reliable web applications. Every piece of data that enters your application, whether from forms, API requests, or URL parameters, should be treated as potentially harmful until properly validated.

Why Validate Input?

flowchart TD A[Input Sources] --> B[URL Parameters] A --> C[Query Strings] A --> D[Request Body] A --> E[Headers] A --> F[File Uploads] B & C & D & E & F --> G[Validation Layer] G --> H{Valid?} H -->|Yes| I[Business Logic] H -->|No| J[Error Response]

Think of input validation as the security checkpoint at an airport. Just as airport security screens passengers and luggage before allowing them onto planes, input validation screens all incoming data before allowing it into your application's core logic.

Introduction to express-validator

express-validator is a set of Express.js middleware that wraps the validator.js library, providing a convenient way to validate and sanitize data in your Express applications.

Key Features

Basic Setup

// Install with npm
// npm install express-validator

const express = require('express');
const { body, validationResult } = require('express-validator');

const app = express();

// Middleware
app.use(express.json());

// Route with validation
app.post('/user', [
  // Validate name
  body('name').notEmpty().withMessage('Name is required'),
  
  // Validate email
  body('email').isEmail().withMessage('Must be a valid email address'),
  
  // Validate password
  body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
], (req, res) => {
  // Check for validation errors
  const errors = validationResult(req);
  
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }
  
  // Process valid input
  // ...
  
  res.status(201).json({ message: 'User created successfully' });
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

express-validator vs Other Solutions

There are several validation libraries available for Node.js applications:

  • express-validator - Middleware-based, integrates well with Express
  • Joi - Schema-based validation, powerful but more verbose
  • Yup - Schema builder with TypeScript support
  • Zod - TypeScript-first schema validation
  • Ajv - JSON Schema validator, very fast

express-validator is often the preferred choice for Express applications due to its middleware approach and Express-specific features.

Core Concepts of express-validator

Validation Chains

Validation chains are the building blocks of express-validator. They define a sequence of validation and sanitization rules that are applied to specific fields in the request.

Creating Validation Chains

const { body, param, query, cookie, header } = require('express-validator');

// Validate body fields
const nameValidator = body('name')
  .notEmpty()
  .withMessage('Name is required')
  .isLength({ min: 2, max: 50 })
  .withMessage('Name must be between 2 and 50 characters');

// Validate URL parameters
const userIdValidator = param('userId')
  .isMongoId()
  .withMessage('Invalid user ID format');

// Validate query parameters
const limitValidator = query('limit')
  .optional()
  .isInt({ min: 1, max: 100 })
  .withMessage('Limit must be between 1 and 100');

// Validate cookies
const tokenValidator = cookie('token')
  .notEmpty()
  .withMessage('Authentication token is required');

// Validate headers
const apiKeyValidator = header('x-api-key')
  .notEmpty()
  .withMessage('API key is required');

Validation Results

After defining validation chains, you need to check if there were any validation errors before proceeding with your business logic.

Checking Validation Results

const { validationResult } = require('express-validator');

app.post('/api/users', [
  // Validation chains...
], (req, res) => {
  // Get validation results
  const errors = validationResult(req);
  
  // Check if there are errors
  if (!errors.isEmpty()) {
    // Return errors to client
    return res.status(400).json({ errors: errors.array() });
  }
  
  // Process valid input
  // ...
});

Customizing Error Messages

// Default error format
/*
{
  "errors": [
    {
      "location": "body",
      "msg": "Invalid email address",
      "param": "email",
      "value": "not-an-email"
    }
  ]
}
*/

// Custom error formatter
app.post('/api/users', [
  // Validation chains...
], (req, res) => {
  const errors = validationResult(req).formatWith(({ location, msg, param, value }) => {
    return {
      field: param,
      message: msg,
      location: location,
      providedValue: value
    };
  });
  
  if (!errors.isEmpty()) {
    return res.status(400).json({
      success: false,
      errors: errors.array()
    });
  }
  
  // Process valid input
  // ...
});
express-validator Flow API Request Validation Chains body('email').isEmail() body('age').isInt({min: 18}) param('id').isUUID() validationResult() Valid? Yes Business Logic Success Response No Error Response

Basic Validation Rules

express-validator provides a wide range of validation rules for different data types. Here are some of the most commonly used ones:

String Validations

Common String Validations

// String presence and length
body('username')
  .exists().withMessage('Username is required')
  .notEmpty().withMessage('Username cannot be empty')
  .isLength({ min: 3, max: 20 }).withMessage('Username must be 3-20 characters');

// String format
body('email')
  .isEmail().withMessage('Must be a valid email')
  .normalizeEmail(); // Sanitizer

// String patterns
body('password')
  .isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
  .matches(/[a-z]/).withMessage('Password must contain at least one lowercase letter')
  .matches(/[A-Z]/).withMessage('Password must contain at least one uppercase letter')
  .matches(/\d/).withMessage('Password must contain at least one number')
  .matches(/[!@#$%^&*(),.?":{}|<>]/).withMessage('Password must contain at least one special character');

// Special strings
body('zipCode').isPostalCode('US').withMessage('Invalid US ZIP code');
body('phone').isMobilePhone('en-US').withMessage('Invalid US phone number');
body('url').isURL().withMessage('Invalid URL');
body('uuid').isUUID().withMessage('Invalid UUID');
body('hexColor').isHexColor().withMessage('Invalid hex color');

Numeric Validations

Common Numeric Validations

// Integer validation
body('age')
  .isInt().withMessage('Age must be an integer')
  .isInt({ min: 0, max: 120 }).withMessage('Age must be between 0 and 120');

// Floating point validation
body('rating')
  .isFloat().withMessage('Rating must be a number')
  .isFloat({ min: 0, max: 5 }).withMessage('Rating must be between 0 and 5');

// Numeric transformations
body('price')
  .isFloat({ min: 0 }).withMessage('Price must be a positive number')
  .toFloat(); // Convert string to float
  
body('quantity')
  .isInt({ min: 1 }).withMessage('Quantity must be at least 1')
  .toInt(); // Convert string to integer

// Divisibility
body('evenNumber')
  .isInt().withMessage('Must be an integer')
  .custom(value => value % 2 === 0).withMessage('Must be an even number');

Date Validations

Common Date Validations

// Date format validation
body('birthdate')
  .isDate().withMessage('Invalid date format');

// Date string format validation
body('appointmentDate')
  .isISO8601().withMessage('Must be a valid ISO 8601 date');

// Date range validation
body('startDate')
  .isISO8601().withMessage('Invalid start date format')
  .custom((value) => {
    const date = new Date(value);
    const now = new Date();
    return date >= now;
  }).withMessage('Start date must be in the future');

// Date comparison
body('endDate')
  .isISO8601().withMessage('Invalid end date format')
  .custom((value, { req }) => {
    const startDate = new Date(req.body.startDate);
    const endDate = new Date(value);
    return endDate > startDate;
  }).withMessage('End date must be after start date');

Boolean and Null Validations

Boolean and Null Validations

// Boolean validation
body('agreeToTerms')
  .isBoolean().withMessage('Must be a boolean value')
  .equals('true').withMessage('You must agree to terms');

// Convert to boolean
body('isActive')
  .isBoolean().withMessage('Must be a boolean value')
  .toBoolean(); // Converts 'true', '1', etc. to true

// Optional fields with conditional validation
body('optionalField')
  .optional() // Field can be undefined or null
  .isString().withMessage('If provided, must be a string');

// Optional with falsy values treated as undefined
body('anotherOptionalField')
  .optional({ nullable: true, checkFalsy: true }) // Treats '', 0, false, null as undefined
  .isEmail().withMessage('If provided, must be an email');

// Explicit null check
body('specificField')
  .custom(value => value === null).withMessage('Field must be null');

Advanced Validation Techniques

Custom Validators

While express-validator provides many built-in validators, you'll often need to create custom validation rules for your specific business requirements.

Creating Custom Validators

// Simple custom validator
body('username')
  .custom(value => {
    // Check that username contains no spaces
    if (/\s/.test(value)) {
      throw new Error('Username cannot contain spaces');
    }
    return true; // Validation passed
  });

// Custom validator with database query
body('email')
  .isEmail().withMessage('Invalid email format')
  .custom(async (email) => {
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      throw new Error('Email already in use');
    }
    return true;
  });

// Access other fields in custom validator
body('passwordConfirmation')
  .custom((value, { req }) => {
    if (value !== req.body.password) {
      throw new Error('Password confirmation does not match password');
    }
    return true;
  });

Conditional Validation

Sometimes you need to apply validation rules conditionally based on other fields or request properties.

Implementing Conditional Validation

// Conditional based on another field's value
body('shippingAddress')
  .custom((value, { req }) => {
    // Only required if shipping option is 'physical'
    if (req.body.productType === 'physical' && !value) {
      throw new Error('Shipping address is required for physical products');
    }
    return true;
  });

// Conditional based on field's presence
body('paymentMethod')
  .custom((value, { req }) => {
    // If credit card is selected, card details are required
    if (value === 'credit_card') {
      if (!req.body.cardNumber) {
        throw new Error('Card number is required');
      }
      if (!req.body.expiryDate) {
        throw new Error('Expiry date is required');
      }
      if (!req.body.cvv) {
        throw new Error('CVV is required');
      }
    }
    return true;
  });

// Validate only if field exists
body('website')
  .if(body('website').exists())
  .isURL()
  .withMessage('Must be a valid URL');

// Different validation based on user role
body('adminCode')
  .if((value, { req }) => req.user && req.user.role === 'admin')
  .notEmpty()
  .withMessage('Admin code is required for admin users');

Sanitization

In addition to validation, express-validator provides sanitization methods to clean and normalize data before processing.

Common Sanitization Methods

// Trim whitespace
body('name')
  .trim() // Remove whitespace from both ends
  .notEmpty()
  .withMessage('Name is required');

// Escape HTML entities
body('comment')
  .escape() // Convert &, <, >, ", ', / to HTML entities
  .notEmpty()
  .withMessage('Comment is required');

// Normalize email
body('email')
  .normalizeEmail({ gmail_remove_dots: false }) // Lowercase domain, remove secondary email (e.g., username+tag@gmail.com -> username@gmail.com)
  .isEmail()
  .withMessage('Invalid email format');

// Convert to lowercase/uppercase
body('username')
  .toLowerCase() // Convert to lowercase
  .isLength({ min: 3 })
  .withMessage('Username must be at least 3 characters');

// Remove non-alphanumeric characters
body('alphanumericField')
  .blacklist('\\W') // Remove anything that's not alphanumeric or underscore
  .isLength({ min: 1 })
  .withMessage('Field is required');

// Convert to boolean/number
body('isActive')
  .toBoolean(); // Converts 'true', '1', etc. to true

body('age')
  .toInt(); // Converts string to integer

// Replace substrings
body('phoneNumber')
  .replaceAll(' ', '') // Remove all spaces
  .matches(/^\d{10}$/)
  .withMessage('Phone number must be 10 digits');

Organizing Validation Logic

As your application grows, it's important to organize your validation logic to keep it maintainable and reusable.

Creating Validation Middleware

Group related validation rules into middleware functions that can be reused across routes:

Validation Middleware Approach

// validation/user.js
const { body } = require('express-validator');

// Validation middleware for user creation
const validateUserCreation = [
  body('name')
    .trim()
    .notEmpty().withMessage('Name is required')
    .isLength({ min: 2, max: 50 }).withMessage('Name must be 2-50 characters'),
    
  body('email')
    .trim()
    .normalizeEmail()
    .isEmail().withMessage('Must be a valid email')
    .custom(async (email) => {
      const existingUser = await User.findOne({ email });
      if (existingUser) {
        throw new Error('Email already in use');
      }
      return true;
    }),
    
  body('password')
    .isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
    .matches(/[a-z]/).withMessage('Password must include lowercase letter')
    .matches(/[A-Z]/).withMessage('Password must include uppercase letter')
    .matches(/\d/).withMessage('Password must include a number'),
    
  body('passwordConfirmation')
    .custom((value, { req }) => {
      if (value !== req.body.password) {
        throw new Error('Password confirmation does not match');
      }
      return true;
    })
];

// Validation middleware for user login
const validateUserLogin = [
  body('email')
    .trim()
    .normalizeEmail()
    .isEmail().withMessage('Must be a valid email'),
    
  body('password')
    .notEmpty().withMessage('Password is required')
];

// Validation middleware for user profile update
const validateProfileUpdate = [
  body('name')
    .optional()
    .trim()
    .isLength({ min: 2, max: 50 }).withMessage('Name must be 2-50 characters'),
    
  body('bio')
    .optional()
    .trim()
    .isLength({ max: 500 }).withMessage('Bio cannot exceed 500 characters'),
    
  body('age')
    .optional()
    .isInt({ min: 13, max: 120 }).withMessage('Age must be between 13-120')
    .toInt()
];

module.exports = {
  validateUserCreation,
  validateUserLogin,
  validateProfileUpdate
};

Using Validation Middleware in Routes

// routes/users.js
const express = require('express');
const { validationResult } = require('express-validator');
const { 
  validateUserCreation, 
  validateUserLogin, 
  validateProfileUpdate 
} = require('../validation/user');
const userController = require('../controllers/user');

const router = express.Router();

// Middleware to check validation results
const checkValidationResult = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }
  next();
};

// User routes with validation
router.post(
  '/register',
  validateUserCreation,
  checkValidationResult,
  userController.register
);

router.post(
  '/login',
  validateUserLogin,
  checkValidationResult,
  userController.login
);

router.put(
  '/profile',
  validateProfileUpdate,
  checkValidationResult,
  userController.updateProfile
);

module.exports = router;

Schema-based Validation

For complex objects, express-validator provides a checkSchema function for schema-based validation:

Using Schema Validation

// validation/product.js
const { checkSchema } = require('express-validator');

const productValidationSchema = {
  name: {
    in: ['body'],
    trim: true,
    notEmpty: {
      errorMessage: 'Product name is required'
    },
    isLength: {
      options: { min: 2, max: 100 },
      errorMessage: 'Product name must be 2-100 characters'
    }
  },
  price: {
    in: ['body'],
    isFloat: {
      options: { min: 0 },
      errorMessage: 'Price must be a positive number'
    },
    toFloat: true
  },
  description: {
    in: ['body'],
    optional: true,
    trim: true,
    isLength: {
      options: { max: 1000 },
      errorMessage: 'Description cannot exceed 1000 characters'
    },
    escape: true // Escapes HTML entities
  },
  category: {
    in: ['body'],
    isIn: {
      options: [['electronics', 'clothing', 'books', 'home', 'beauty']],
      errorMessage: 'Invalid product category'
    }
  },
  stock: {
    in: ['body'],
    isInt: {
      options: { min: 0 },
      errorMessage: 'Stock must be a non-negative integer'
    },
    toInt: true
  },
  images: {
    in: ['body'],
    optional: true,
    isArray: {
      errorMessage: 'Images must be an array'
    },
    custom: {
      options: (value) => {
        if (!Array.isArray(value)) return true;
        return value.every(url => /^https?:\/\/.+/.test(url));
      },
      errorMessage: 'Each image must be a valid URL'
    }
  },
  featured: {
    in: ['body'],
    optional: true,
    isBoolean: {
      errorMessage: 'Featured must be a boolean'
    },
    toBoolean: true
  },
  tags: {
    in: ['body'],
    optional: true,
    isArray: {
      errorMessage: 'Tags must be an array'
    },
    custom: {
      options: (value) => {
        if (!Array.isArray(value)) return true;
        return value.every(tag => typeof tag === 'string' && tag.length > 0);
      },
      errorMessage: 'Each tag must be a non-empty string'
    }
  }
};

// Validate product creation
const validateProductCreation = checkSchema(productValidationSchema);

// For updates, make all fields optional
const validateProductUpdate = checkSchema({
  ...productValidationSchema,
  name: {
    ...productValidationSchema.name,
    optional: true
  },
  price: {
    ...productValidationSchema.price,
    optional: true
  },
  category: {
    ...productValidationSchema.category,
    optional: true
  },
  stock: {
    ...productValidationSchema.stock,
    optional: true
  }
});

module.exports = {
  validateProductCreation,
  validateProductUpdate
};

Using Schema Validation in Routes

// routes/products.js
const express = require('express');
const { validationResult } = require('express-validator');
const { 
  validateProductCreation, 
  validateProductUpdate 
} = require('../validation/product');
const productController = require('../controllers/product');

const router = express.Router();

// Check validation results
const checkValidationResult = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }
  next();
};

router.post(
  '/',
  validateProductCreation,
  checkValidationResult,
  productController.createProduct
);

router.put(
  '/:id',
  validateProductUpdate,
  checkValidationResult,
  productController.updateProduct
);

module.exports = router;

Handling Validation Errors

Providing clear and helpful error messages is crucial for a good user experience.

Customizing Error Messages

You can customize error messages in several ways:

Error Message Customization

// Method 1: Using withMessage()
body('email')
  .isEmail()
  .withMessage('Please enter a valid email address');

// Method 2: Providing error message in options
body('age').isInt({ 
  min: 18,
  errorMessage: 'You must be at least 18 years old'
});

// Method 3: Custom validator with specific messages
body('password')
  .custom((value) => {
    if (value.length < 8) {
      throw new Error('Password must be at least 8 characters');
    }
    if (!/[A-Z]/.test(value)) {
      throw new Error('Password must contain at least one uppercase letter');
    }
    if (!/\d/.test(value)) {
      throw new Error('Password must contain at least one number');
    }
    return true;
  });

// Method 4: Schema-based with errorMessage property
checkSchema({
  username: {
    isLength: {
      options: { min: 3 },
      errorMessage: 'Username must be at least 3 characters'
    }
  }
});

Formatting Error Responses

You can customize the format of validation error responses to match your API's error format:

Error Response Formatting

// Custom error formatting middleware
const handleValidationErrors = (req, res, next) => {
  const errors = validationResult(req);
  
  if (!errors.isEmpty()) {
    // Format errors according to your API's error format
    const formattedErrors = errors.array().map(error => ({
      field: error.param,
      message: error.msg,
      location: error.location,
      value: error.value
    }));
    
    return res.status(400).json({
      success: false,
      message: 'Validation failed',
      errors: formattedErrors
    });
  }
  
  next();
};

// Group errors by field
const handleValidationErrorsByField = (req, res, next) => {
  const errors = validationResult(req);
  
  if (!errors.isEmpty()) {
    // Group errors by field
    const errorsByField = errors.array().reduce((acc, error) => {
      if (!acc[error.param]) {
        acc[error.param] = [];
      }
      acc[error.param].push(error.msg);
      return acc;
    }, {});
    
    return res.status(400).json({
      success: false,
      message: 'Validation failed',
      errors: errorsByField
    });
  }
  
  next();
};

Localization

For international applications, express-validator supports error message localization:

Localizing Error Messages

// Install i18n: npm install i18n
const i18n = require('i18n');

// Configure i18n
i18n.configure({
  locales: ['en', 'es', 'fr'],
  directory: __dirname + '/locales',
  defaultLocale: 'en',
  objectNotation: true
});

// Middleware to set locale
app.use(i18n.init);

// Localized validation
const validateUserWithLocalization = [
  body('name')
    .notEmpty()
    .withMessage((value, { req }) => req.__('validation.name.required')),
    
  body('email')
    .isEmail()
    .withMessage((value, { req }) => req.__('validation.email.invalid')),
    
  body('password')
    .isLength({ min: 8 })
    .withMessage((value, { req }) => req.__('validation.password.tooShort', { min: 8 }))
];

// Example locale file (locales/en.json)
/*
{
  "validation": {
    "name": {
      "required": "Name is required"
    },
    "email": {
      "invalid": "Please enter a valid email address"
    },
    "password": {
      "tooShort": "Password must be at least {{min}} characters"
    }
  }
}
*/

Best Practices

Here are some best practices to follow when implementing input validation with express-validator:

flowchart LR A[Validation
Best Practices] --> B[Validate ALL Input] A --> C[Sanitize Before Validate] A --> D[Use Type Conversion] A --> E[Whitelist Expected Fields] A --> F[Separate Validation Logic] A --> G[Use Custom Error Format] A --> H[Test Validation Rules]

Validate All Input Sources

Ensure you validate data from all possible sources:

Sanitize Input

Apply sanitization rules before validation to ensure consistent data format:

Validate Only What's Needed

For update operations, only validate fields that are provided:

Security Considerations

Validation is an important security layer:

Performance Considerations

Validation adds overhead to request processing:

Performance Optimization Example

// Use bail() to stop on first error
body('email')
  .trim()
  .notEmpty().withMessage('Email is required').bail()
  .isEmail().withMessage('Invalid email format').bail()
  .custom(async (email) => {
    // This expensive DB query won't run if previous validations fail
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      throw new Error('Email already in use');
    }
    return true;
  });

// Cache-friendly custom validator
const emailExistsValidator = (() => {
  let cache = {};
  let cacheExpiry = Date.now() + 60000; // 1 minute
  
  return async (email) => {
    // Clear cache after expiry
    if (Date.now() > cacheExpiry) {
      cache = {};
      cacheExpiry = Date.now() + 60000;
    }
    
    // Check cache first
    if (cache[email] !== undefined) {
      if (cache[email]) {
        throw new Error('Email already in use');
      }
      return true;
    }
    
    // Query database if not in cache
    const existingUser = await User.findOne({ email });
    cache[email] = !!existingUser;
    
    if (existingUser) {
      throw new Error('Email already in use');
    }
    return true;
  };
})();

// Use the cached validator
body('email')
  .isEmail().withMessage('Invalid email format')
  .custom(emailExistsValidator);

Real-world Validation Example

Let's build a complete real-world example for an e-commerce product API:

Complete Product Validation Example

// validation/product.js
const { body, param, query, checkSchema } = require('express-validator');
const Category = require('../models/category');
const Product = require('../models/product');

// Utility function to validate MongoDB ObjectID
const isValidObjectId = (value) => {
  return /^[0-9a-fA-F]{24}$/.test(value);
};

// Validate product ID parameter
const validateProductId = [
  param('id')
    .custom((value) => {
      if (!isValidObjectId(value)) {
        throw new Error('Invalid product ID format');
      }
      return true;
    })
];

// Validate product search parameters
const validateProductSearch = [
  query('category')
    .optional()
    .custom(async (value) => {
      if (!isValidObjectId(value)) {
        throw new Error('Invalid category ID format');
      }
      
      const categoryExists = await Category.exists({ _id: value });
      if (!categoryExists) {
        throw new Error('Category not found');
      }
      
      return true;
    }),
    
  query('minPrice')
    .optional()
    .isFloat({ min: 0 })
    .withMessage('Minimum price must be a non-negative number')
    .toFloat(),
    
  query('maxPrice')
    .optional()
    .isFloat({ min: 0 })
    .withMessage('Maximum price must be a non-negative number')
    .toFloat()
    .custom((value, { req }) => {
      const minPrice = req.query.minPrice;
      if (minPrice && value < minPrice) {
        throw new Error('Maximum price must be greater than minimum price');
      }
      return true;
    }),
    
  query('sortBy')
    .optional()
    .isIn(['name', 'price', 'createdAt', 'rating'])
    .withMessage('Sort field must be one of: name, price, createdAt, rating'),
    
  query('sortOrder')
    .optional()
    .isIn(['asc', 'desc'])
    .withMessage('Sort order must be asc or desc'),
    
  query('page')
    .optional()
    .isInt({ min: 1 })
    .withMessage('Page must be a positive integer')
    .toInt(),
    
  query('limit')
    .optional()
    .isInt({ min: 1, max: 100 })
    .withMessage('Limit must be between 1 and 100')
    .toInt()
];

// Validate product creation
const validateCreateProduct = [
  body('name')
    .trim()
    .notEmpty().withMessage('Product name is required').bail()
    .isLength({ min: 3, max: 100 }).withMessage('Product name must be 3-100 characters').bail()
    .custom(async (name) => {
      const existingProduct = await Product.findOne({ name });
      if (existingProduct) {
        throw new Error('Product with this name already exists');
      }
      return true;
    }),
    
  body('description')
    .trim()
    .notEmpty().withMessage('Product description is required').bail()
    .isLength({ min: 10, max: 2000 }).withMessage('Description must be 10-2000 characters'),
    
  body('price')
    .notEmpty().withMessage('Price is required').bail()
    .isFloat({ min: 0.01 }).withMessage('Price must be at least 0.01')
    .toFloat(),
    
  body('category')
    .notEmpty().withMessage('Category is required').bail()
    .custom(async (value) => {
      if (!isValidObjectId(value)) {
        throw new Error('Invalid category ID format');
      }
      
      const categoryExists = await Category.exists({ _id: value });
      if (!categoryExists) {
        throw new Error('Category not found');
      }
      
      return true;
    }),
    
  body('stock')
    .notEmpty().withMessage('Stock is required').bail()
    .isInt({ min: 0 }).withMessage('Stock must be a non-negative integer')
    .toInt(),
    
  body('images')
    .isArray({ min: 1 }).withMessage('At least one product image is required').bail()
    .custom((images) => {
      if (!images.every(url => typeof url === 'string' && url.trim().length > 0)) {
        throw new Error('Each image must be a non-empty string');
      }
      
      if (!images.every(url => /^https?:\/\/.+/.test(url))) {
        throw new Error('Each image must be a valid URL');
      }
      
      return true;
    }),
    
  body('features')
    .optional()
    .isArray().withMessage('Features must be an array').bail()
    .custom((features) => {
      if (!features.every(feature => 
        typeof feature === 'object' && 
        typeof feature.name === 'string' && 
        typeof feature.value === 'string')) {
        throw new Error('Each feature must have a name and value as strings');
      }
      return true;
    }),
    
  body('availability')
    .isIn(['in_stock', 'out_of_stock', 'pre_order', 'discontinued'])
    .withMessage('Invalid availability status'),
    
  body('dimensions')
    .optional()
    .isObject().withMessage('Dimensions must be an object').bail()
    .custom((dimensions) => {
      const requiredProps = ['width', 'height', 'depth', 'weight'];
      const missingProps = requiredProps.filter(prop => !(prop in dimensions));
      
      if (missingProps.length > 0) {
        throw new Error(`Dimensions missing required properties: ${missingProps.join(', ')}`);
      }
      
      if (!Object.values(dimensions).every(val => typeof val === 'number' && val >= 0)) {
        throw new Error('All dimension values must be non-negative numbers');
      }
      
      return true;
    }),
    
  body('tags')
    .optional()
    .isArray().withMessage('Tags must be an array').bail()
    .custom((tags) => {
      if (!tags.every(tag => typeof tag === 'string' && tag.trim().length > 0)) {
        throw new Error('Each tag must be a non-empty string');
      }
      return true;
    }),
    
  body('isActive')
    .optional()
    .isBoolean().withMessage('isActive must be a boolean')
    .toBoolean(),
    
  body('discount')
    .optional()
    .isObject().withMessage('Discount must be an object').bail()
    .custom((discount, { req }) => {
      if (!('percentage' in discount) || !('validUntil' in discount)) {
        throw new Error('Discount must have percentage and validUntil properties');
      }
      
      if (typeof discount.percentage !== 'number' || 
          discount.percentage < 0 || 
          discount.percentage > 100) {
        throw new Error('Discount percentage must be between 0 and 100');
      }
      
      const validUntil = new Date(discount.validUntil);
      if (isNaN(validUntil.getTime())) {
        throw new Error('Discount validUntil must be a valid date');
      }
      
      if (validUntil < new Date()) {
        throw new Error('Discount validUntil must be in the future');
      }
      
      return true;
    })
];

// Validate product update (similar to create but fields are optional)
const validateUpdateProduct = [
  body('name')
    .optional()
    .trim()
    .isLength({ min: 3, max: 100 }).withMessage('Product name must be 3-100 characters').bail()
    .custom(async (name, { req }) => {
      const existingProduct = await Product.findOne({ 
        name, 
        _id: { $ne: req.params.id }  // Exclude current product
      });
      
      if (existingProduct) {
        throw new Error('Product with this name already exists');
      }
      return true;
    }),
    
  body('description')
    .optional()
    .trim()
    .isLength({ min: 10, max: 2000 }).withMessage('Description must be 10-2000 characters'),
    
  body('price')
    .optional()
    .isFloat({ min: 0.01 }).withMessage('Price must be at least 0.01')
    .toFloat(),
    
  body('category')
    .optional()
    .custom(async (value) => {
      if (!isValidObjectId(value)) {
        throw new Error('Invalid category ID format');
      }
      
      const categoryExists = await Category.exists({ _id: value });
      if (!categoryExists) {
        throw new Error('Category not found');
      }
      
      return true;
    }),
    
  body('stock')
    .optional()
    .isInt({ min: 0 }).withMessage('Stock must be a non-negative integer')
    .toInt(),
    
  body('images')
    .optional()
    .isArray().withMessage('Images must be an array').bail()
    .custom((images) => {
      if (!images.every(url => typeof url === 'string' && url.trim().length > 0)) {
        throw new Error('Each image must be a non-empty string');
      }
      
      if (!images.every(url => /^https?:\/\/.+/.test(url))) {
        throw new Error('Each image must be a valid URL');
      }
      
      return true;
    }),
    
  // ... other fields (same as create but with .optional())
  
  // Validate that at least one field is being updated
  body()
    .custom((body) => {
      if (Object.keys(body).length === 0) {
        throw new Error('At least one field must be provided for update');
      }
      return true;
    })
];

module.exports = {
  validateProductId,
  validateProductSearch,
  validateCreateProduct,
  validateUpdateProduct
};

Using Product Validation in Routes

// routes/products.js
const express = require('express');
const { validationResult } = require('express-validator');
const productController = require('../controllers/product');
const {
  validateProductId,
  validateProductSearch,
  validateCreateProduct,
  validateUpdateProduct
} = require('../validation/product');
const { isAuthenticated, isAdmin } = require('../middleware/auth');

const router = express.Router();

// Middleware to check validation results
const handleValidationErrors = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({
      success: false,
      message: 'Validation failed',
      errors: errors.array()
    });
  }
  next();
};

// Public routes
router.get('/', validateProductSearch, handleValidationErrors, productController.getProducts);
router.get('/:id', validateProductId, handleValidationErrors, productController.getProductById);

// Protected routes
router.post(
  '/',
  isAuthenticated,
  isAdmin,
  validateCreateProduct,
  handleValidationErrors,
  productController.createProduct
);

router.put(
  '/:id',
  isAuthenticated,
  isAdmin,
  validateProductId,
  validateUpdateProduct,
  handleValidationErrors,
  productController.updateProduct
);

router.delete(
  '/:id',
  isAuthenticated,
  isAdmin,
  validateProductId,
  handleValidationErrors,
  productController.deleteProduct
);

module.exports = router;

Practical Exercises

Exercise 1: User Registration API

Create validation for a user registration API with the following requirements:

  • Username: 3-20 characters, alphanumeric only, unique
  • Email: Valid format, unique
  • Password: At least 8 characters, must contain uppercase, lowercase, number
  • Password confirmation: Must match password
  • Full name: Required, 2-50 characters
  • Age: Optional, integer, 13-120
  • Bio: Optional, max 500 characters
  • Terms acceptance: Must be true

Implement proper error handling and testing.

Exercise 2: Blog Post API

Create validation for a blog post API with the following requirements:

  • Create post endpoint with validation for:
    • Title: 5-100 characters, required
    • Content: At least 100 characters, required
    • Tags: Array of strings, optional
    • Category: Valid category ID from database
    • Status: One of 'draft', 'published', 'archived'
    • Featured image: Valid URL, optional
  • Update post endpoint with similar validation but all fields optional
  • Search posts endpoint with validation for sorting, filtering, and pagination

Organize the validation logic using schemas and reusable components.

Additional Resources

Documentation

Security Resources

Other Validation Libraries