Custom Error Classes in Express.js

Building a robust error handling system

Understanding JavaScript's Error System

JavaScript provides a built-in Error class that serves as the foundation for all errors in the language. Before diving into custom error classes for Express applications, it's essential to understand how JavaScript's native error system works.

Native JavaScript Error Types

JavaScript includes several built-in error types that extend the base Error class:

JavaScript's Native Error Structure

// Creating a basic error
const error = new Error('Something went wrong');

console.log(error.message); // 'Something went wrong'
console.log(error.name);    // 'Error'
console.log(error.stack);   // Stack trace showing where the error occurred

// Other error types
const typeError = new TypeError('Expected string but got number');
console.log(typeError.name);    // 'TypeError'
console.log(typeError.message); // 'Expected string but got number'

// Try/catch example
try {
  const obj = null;
  console.log(obj.property); // This will throw a TypeError
} catch (error) {
  console.log(error.name);     // 'TypeError'
  console.log(error.message);  // 'Cannot read property 'property' of null'
  
  // We can determine the type of error
  if (error instanceof TypeError) {
    console.log('This is a type error');
  }
}
classDiagram Error <|-- SyntaxError Error <|-- ReferenceError Error <|-- TypeError Error <|-- RangeError Error <|-- URIError Error <|-- EvalError Error <|-- AggregateError class Error { +String message +String name +String stack +constructor(message) } class TypeError { +String name = "TypeError" } class RangeError { +String name = "RangeError" } class SyntaxError { +String name = "SyntaxError" } class ReferenceError { +String name = "ReferenceError" } class URIError { +String name = "URIError" } class EvalError { +String name = "EvalError" } class AggregateError { +String name = "AggregateError" +Array errors }

Think of JavaScript's error system as a family tree. The Error class is the parent, with specialized children (TypeError, RangeError, etc.) that handle specific error situations. When you create custom error classes, you're essentially extending this family to include new specialized members tailored to your application's needs.

Why Create Custom Error Classes?

While JavaScript's built-in error types are useful, they don't cover all the specific error scenarios you might encounter in a web application. Custom error classes provide several benefits:

Benefits of Custom Error Classes

Custom Error Classes in Express Applications Native JS Errors TypeError RangeError ReferenceError SyntaxError Other built-in errors Extend AppError Base Custom Error Extend Domain-Specific Errors ValidationError NotFoundError AuthenticationError AuthorizationError DatabaseError

Custom error classes act as translators between your application's internal errors and the responses sent to clients. They help categorize, enrich, and standardize error information, making your application more maintainable and user-friendly.

Creating Custom Error Classes in JavaScript

Creating custom error classes in JavaScript is straightforward. You extend the built-in Error class and add your own properties and methods.

Basic Custom Error Class

// Basic custom error class
class CustomError extends Error {
  constructor(message) {
    super(message); // Call parent constructor
    this.name = 'CustomError'; // Set the error name
    
    // Capture stack trace, excluding the constructor call from it
    Error.captureStackTrace(this, this.constructor);
  }
}

// Using the custom error
try {
  throw new CustomError('This is a custom error');
} catch (error) {
  console.log(error.name);    // 'CustomError'
  console.log(error.message); // 'This is a custom error'
  console.log(error.stack);   // Stack trace starting from where the error was thrown
}

Adding Custom Properties

// Custom error with additional properties
class ValidationError extends Error {
  constructor(message, field, value) {
    super(message);
    this.name = 'ValidationError';
    this.field = field;
    this.value = value;
    this.timestamp = new Date();
    
    Error.captureStackTrace(this, this.constructor);
  }
  
  // Custom method
  toJSON() {
    return {
      error: {
        type: this.name,
        message: this.message,
        field: this.field,
        value: this.value,
        timestamp: this.timestamp
      }
    };
  }
}

// Using the validation error
try {
  const age = -5;
  if (age < 0) {
    throw new ValidationError('Age must be a positive number', 'age', age);
  }
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(error.field);     // 'age'
    console.log(error.value);     // -5
    console.log(error.timestamp); // Current date and time
    console.log(error.toJSON());  // Formatted error object
  }
}

About Error.captureStackTrace

The Error.captureStackTrace(this, this.constructor) line is important for custom errors. It:

  • Creates a .stack property on the error object
  • Omits the error constructor function itself from the stack trace
  • Makes the stack trace start from where the error was thrown, not created
  • Helps with debugging by providing cleaner stack traces

V8-specific feature (works in Node.js and Chromium-based browsers), but gracefully ignored in other environments.

Custom Error Classes for Express Applications

When building Express applications, it's helpful to create a hierarchy of error classes tailored to web API scenarios.

Base Application Error

Start with a base error class that all other custom errors will extend. This class should include common properties relevant to HTTP APIs:

Base Application Error Class

// errors/AppError.js
class AppError extends Error {
  constructor(message, statusCode = 500, type = 'SERVER_ERROR', isOperational = true) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.type = type;
    this.isOperational = isOperational; // Flag to distinguish operational vs programmer errors
    this.timestamp = new Date();
    
    Error.captureStackTrace(this, this.constructor);
  }
  
  /**
   * Converts the error to a format suitable for API responses
   */
  toJSON() {
    return {
      error: {
        type: this.type,
        message: this.message,
        statusCode: this.statusCode,
        timestamp: this.timestamp
      }
    };
  }
}

module.exports = AppError;

The isOperational Flag

The isOperational flag is a common pattern for distinguishing between two types of errors:

This distinction helps with error handling strategies - operational errors are handled gracefully, while programmer errors often trigger alerts for developers.

Common Express API Error Classes

Let's build a hierarchy of error classes useful for Express APIs:

classDiagram Error <|-- AppError AppError <|-- ValidationError AppError <|-- NotFoundError AppError <|-- AuthenticationError AppError <|-- AuthorizationError AppError <|-- DatabaseError AppError <|-- RateLimitError AppError <|-- ConflictError class Error { +String message +String name +String stack } class AppError { +Number statusCode +String type +Boolean isOperational +Date timestamp +toJSON() } class ValidationError { +Array invalidFields +statusCode = 400 +type = "VALIDATION_ERROR" } class NotFoundError { +String resource +statusCode = 404 +type = "NOT_FOUND" } class AuthenticationError { +statusCode = 401 +type = "AUTHENTICATION_ERROR" } class AuthorizationError { +statusCode = 403 +type = "AUTHORIZATION_ERROR" } class DatabaseError { +String operation +Object originalError +statusCode = 500 +type = "DATABASE_ERROR" } class RateLimitError { +Number retryAfter +statusCode = 429 +type = "RATE_LIMIT_EXCEEDED" } class ConflictError { +String conflictField +statusCode = 409 +type = "CONFLICT_ERROR" }

Common API Error Classes

// errors/index.js
const AppError = require('./AppError');

/**
 * Validation Error (400 Bad Request)
 * Used for input validation failures
 */
class ValidationError extends AppError {
  constructor(message = 'Validation failed', invalidFields = []) {
    super(message, 400, 'VALIDATION_ERROR');
    this.invalidFields = invalidFields;
  }
  
  toJSON() {
    const json = super.toJSON();
    json.error.invalidFields = this.invalidFields;
    return json;
  }
}

/**
 * Not Found Error (404 Not Found)
 * Used when a requested resource doesn't exist
 */
class NotFoundError extends AppError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404, 'NOT_FOUND');
    this.resource = resource;
  }
  
  toJSON() {
    const json = super.toJSON();
    json.error.resource = this.resource;
    return json;
  }
}

/**
 * Authentication Error (401 Unauthorized)
 * Used for authentication failures
 */
class AuthenticationError extends AppError {
  constructor(message = 'Authentication failed') {
    super(message, 401, 'AUTHENTICATION_ERROR');
  }
}

/**
 * Authorization Error (403 Forbidden)
 * Used when a user lacks permission for an action
 */
class AuthorizationError extends AppError {
  constructor(message = 'Not authorized') {
    super(message, 403, 'AUTHORIZATION_ERROR');
  }
}

/**
 * Database Error (500 Internal Server Error)
 * Used for database operation failures
 */
class DatabaseError extends AppError {
  constructor(message = 'Database operation failed', operation = '', originalError = null) {
    super(message, 500, 'DATABASE_ERROR');
    this.operation = operation;
    this.originalError = originalError;
  }
  
  toJSON() {
    const json = super.toJSON();
    json.error.operation = this.operation;
    // Don't include the original error in JSON output
    // as it might contain sensitive information
    return json;
  }
}

/**
 * Rate Limit Error (429 Too Many Requests)
 * Used when a client exceeds rate limits
 */
class RateLimitError extends AppError {
  constructor(message = 'Rate limit exceeded', retryAfter = 60) {
    super(message, 429, 'RATE_LIMIT_EXCEEDED');
    this.retryAfter = retryAfter;
  }
  
  toJSON() {
    const json = super.toJSON();
    json.error.retryAfter = this.retryAfter;
    return json;
  }
}

/**
 * Conflict Error (409 Conflict)
 * Used when a resource already exists or conflicts with another
 */
class ConflictError extends AppError {
  constructor(message = 'Resource conflict', conflictField = '') {
    super(message, 409, 'CONFLICT_ERROR');
    this.conflictField = conflictField;
  }
  
  toJSON() {
    const json = super.toJSON();
    if (this.conflictField) {
      json.error.conflictField = this.conflictField;
    }
    return json;
  }
}

/**
 * Service Unavailable Error (503 Service Unavailable)
 * Used when a service is temporarily unavailable
 */
class ServiceUnavailableError extends AppError {
  constructor(message = 'Service temporarily unavailable', retryAfter = null) {
    super(message, 503, 'SERVICE_UNAVAILABLE');
    this.retryAfter = retryAfter;
  }
  
  toJSON() {
    const json = super.toJSON();
    if (this.retryAfter) {
      json.error.retryAfter = this.retryAfter;
    }
    return json;
  }
}

/**
 * Bad Gateway Error (502 Bad Gateway)
 * Used when an upstream service returns an invalid response
 */
class BadGatewayError extends AppError {
  constructor(message = 'Bad gateway', service = '') {
    super(message, 502, 'BAD_GATEWAY');
    this.service = service;
  }
  
  toJSON() {
    const json = super.toJSON();
    if (this.service) {
      json.error.service = this.service;
    }
    return json;
  }
}

module.exports = {
  AppError,
  ValidationError,
  NotFoundError,
  AuthenticationError,
  AuthorizationError,
  DatabaseError,
  RateLimitError,
  ConflictError,
  ServiceUnavailableError,
  BadGatewayError
};

Using Custom Error Classes in Express Routes

Now that we've defined our error classes, let's see how to use them effectively in Express routes.

Using Custom Errors in Routes

// routes/users.js
const express = require('express');
const router = express.Router();
const User = require('../models/User');
const { 
  NotFoundError, 
  ValidationError, 
  AuthenticationError,
  ConflictError
} = require('../errors');

// Get user by ID
router.get('/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    
    if (!user) {
      // Use NotFoundError for missing resources
      throw new NotFoundError('User');
    }
    
    res.json(user);
  } catch (err) {
    next(err); // Pass error to error handling middleware
  }
});

// Create new user
router.post('/', async (req, res, next) => {
  try {
    const { email, username, password } = req.body;
    
    // Validate required fields
    const invalidFields = [];
    
    if (!email) {
      invalidFields.push({ field: 'email', message: 'Email is required' });
    }
    
    if (!username) {
      invalidFields.push({ field: 'username', message: 'Username is required' });
    }
    
    if (!password || password.length < 8) {
      invalidFields.push({ field: 'password', message: 'Password must be at least 8 characters' });
    }
    
    if (invalidFields.length > 0) {
      // Use ValidationError for invalid input
      throw new ValidationError('Invalid user data', invalidFields);
    }
    
    // Check if email already exists
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      // Use ConflictError for duplicate resources
      throw new ConflictError('Email already in use', 'email');
    }
    
    // Create user
    const user = await User.create({ email, username, password });
    
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
});

// Login user
router.post('/login', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    
    // Validate required fields
    if (!email || !password) {
      throw new ValidationError('Email and password are required');
    }
    
    // Find user by email
    const user = await User.findOne({ email });
    if (!user) {
      // Use AuthenticationError for login failures
      // Note: In real applications, avoid revealing whether the email exists
      throw new AuthenticationError('Invalid email or password');
    }
    
    // Check password
    const isMatch = await user.comparePassword(password);
    if (!isMatch) {
      throw new AuthenticationError('Invalid email or password');
    }
    
    // Generate token and respond
    const token = user.generateAuthToken();
    res.json({ token });
  } catch (err) {
    next(err);
  }
});

module.exports = router;

Error Handler Middleware

// middleware/errorHandler.js
const { AppError } = require('../errors');

// Development error handler - with details
const developmentErrorHandler = (err, req, res, next) => {
  console.error(err);
  
  // Set status code
  const statusCode = err.statusCode || 500;
  
  // Prepare response
  let errorResponse = {
    error: {
      message: err.message || 'Internal Server Error',
      type: err.type || 'SERVER_ERROR',
      stack: err.stack
    }
  };
  
  // Add additional fields if available
  if (err instanceof AppError) {
    errorResponse = err.toJSON();
    errorResponse.error.stack = err.stack;
  }
  
  res.status(statusCode).json(errorResponse);
};

// Production error handler - without stack traces and sensitive info
const productionErrorHandler = (err, req, res, next) => {
  // Log error for server monitoring
  console.error(err);
  
  // Set status code
  const statusCode = err.statusCode || 500;
  
  // Operational errors can be sent to client with details
  if (err instanceof AppError && err.isOperational) {
    return res.status(statusCode).json(err.toJSON());
  }
  
  // For non-operational errors, send a generic message
  res.status(500).json({
    error: {
      message: 'Something went wrong. Please try again later.',
      type: 'SERVER_ERROR'
    }
  });
};

// Choose the appropriate error handler based on environment
const errorHandler = process.env.NODE_ENV === 'production'
  ? productionErrorHandler
  : developmentErrorHandler;

module.exports = errorHandler;

Setting Up the Error Handler

Don't forget to register the error handler in your main Express application:

Registering Error Handler in Express App

// app.js
const express = require('express');
const errorHandler = require('./middleware/errorHandler');
const userRoutes = require('./routes/users');

const app = express();

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

// Routes
app.use('/api/users', userRoutes);
// ... other routes

// 404 handler - for routes that don't exist
app.use((req, res, next) => {
  const error = new NotFoundError('Route');
  next(error);
});

// Global error handler - must be last middleware
app.use(errorHandler);

module.exports = app;

Advanced Error Handling Patterns

Let's explore some advanced patterns for working with custom error classes in Express applications.

Converting Third-party Errors

It's common to work with libraries and services that throw their own error types. You can create wrapper functions to convert these to your custom error classes:

Converting Database Errors

// utils/errorHandlers.js
const { 
  ValidationError, 
  DatabaseError, 
  NotFoundError,
  ConflictError 
} = require('../errors');

/**
 * Handles Mongoose/MongoDB errors and converts them to custom errors
 */
const handleMongooseError = (err) => {
  // Validation errors
  if (err.name === 'ValidationError') {
    const invalidFields = Object.keys(err.errors).map(field => ({
      field,
      message: err.errors[field].message
    }));
    
    return new ValidationError('Validation failed', invalidFields);
  }
  
  // Duplicate key error (unique constraint violation)
  if (err.name === 'MongoError' && err.code === 11000) {
    const field = Object.keys(err.keyPattern)[0];
    return new ConflictError(`${field} already exists`, field);
  }
  
  // Cast errors (invalid ID format, etc.)
  if (err.name === 'CastError') {
    if (err.kind === 'ObjectId') {
      return new ValidationError(`Invalid ${err.path} format`);
    }
  }
  
  // Default to DatabaseError
  return new DatabaseError('Database operation failed', '', err);
};

/**
 * Wraps database operations with error conversion
 */
const withErrorHandling = (fn) => async (...args) => {
  try {
    return await fn(...args);
  } catch (err) {
    throw handleMongooseError(err);
  }
};

module.exports = {
  handleMongooseError,
  withErrorHandling
};

Using the Error Handler Wrapper

// services/userService.js
const User = require('../models/User');
const { withErrorHandling } = require('../utils/errorHandlers');
const { NotFoundError } = require('../errors');

// Original function
const findUserById = async (id) => {
  const user = await User.findById(id);
  if (!user) {
    throw new NotFoundError('User');
  }
  return user;
};

// Wrapped function with error handling
const findUserByIdSafe = withErrorHandling(findUserById);

// Usage in routes
router.get('/:id', async (req, res, next) => {
  try {
    // Any Mongoose errors will be converted to custom errors
    const user = await findUserByIdSafe(req.params.id);
    res.json(user);
  } catch (err) {
    next(err);
  }
});

Error Factory Pattern

For more complex applications, an error factory pattern can help create consistent errors:

Error Factory Implementation

// utils/errorFactory.js
const { 
  ValidationError, 
  NotFoundError, 
  AuthenticationError, 
  AuthorizationError, 
  DatabaseError,
  ConflictError
} = require('../errors');

/**
 * Factory for creating common error types
 */
const ErrorFactory = {
  /**
   * Create a validation error
   */
  validation: (message, fields = []) => {
    return new ValidationError(message, fields);
  },
  
  /**
   * Create a not found error
   */
  notFound: (resource) => {
    return new NotFoundError(resource);
  },
  
  /**
   * Create an authentication error
   */
  authentication: (message = 'Authentication failed') => {
    return new AuthenticationError(message);
  },
  
  /**
   * Create an authorization error
   */
  authorization: (message = 'Not authorized') => {
    return new AuthorizationError(message);
  },
  
  /**
   * Create a database error
   */
  database: (message, operation, originalError) => {
    return new DatabaseError(message, operation, originalError);
  },
  
  /**
   * Create a conflict error
   */
  conflict: (message, field) => {
    return new ConflictError(message, field);
  }
};

module.exports = ErrorFactory;

Using the Error Factory

// routes/products.js
const express = require('express');
const router = express.Router();
const Product = require('../models/Product');
const ErrorFactory = require('../utils/errorFactory');

router.get('/:id', async (req, res, next) => {
  try {
    const product = await Product.findById(req.params.id);
    
    if (!product) {
      throw ErrorFactory.notFound('Product');
    }
    
    res.json(product);
  } catch (err) {
    next(err);
  }
});

router.post('/', async (req, res, next) => {
  try {
    const { name, price, description } = req.body;
    
    // Validate fields
    const invalidFields = [];
    
    if (!name) {
      invalidFields.push({ field: 'name', message: 'Product name is required' });
    }
    
    if (!price || isNaN(price) || price <= 0) {
      invalidFields.push({ field: 'price', message: 'Price must be a positive number' });
    }
    
    if (invalidFields.length > 0) {
      throw ErrorFactory.validation('Invalid product data', invalidFields);
    }
    
    // Check if product name already exists
    const existingProduct = await Product.findOne({ name });
    if (existingProduct) {
      throw ErrorFactory.conflict('Product name already exists', 'name');
    }
    
    // Create product
    const product = await Product.create({ name, price, description });
    
    res.status(201).json(product);
  } catch (err) {
    next(err);
  }
});

Async Error Wrapper

To avoid repetitive try/catch blocks in your route handlers, you can create a wrapper function:

Async Route Handler Wrapper

// utils/asyncHandler.js
/**
 * Wraps an async route handler and forwards errors to Express error middleware
 * 
 * @param {Function} fn - Async route handler function
 * @returns {Function} Wrapped route handler
 */
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

module.exports = asyncHandler;

Using the Async Handler

// routes/users.js
const express = require('express');
const router = express.Router();
const User = require('../models/User');
const { NotFoundError } = require('../errors');
const asyncHandler = require('../utils/asyncHandler');

// Get user by ID - without try/catch
router.get('/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  
  if (!user) {
    throw new NotFoundError('User');
  }
  
  res.json(user);
}));

// Create user - without try/catch
router.post('/', asyncHandler(async (req, res) => {
  const { email, username, password } = req.body;
  
  // Validation logic...
  
  const user = await User.create({ email, username, password });
  
  res.status(201).json(user);
}));

module.exports = router;

Domain-Specific Error Classes

In real-world applications, you might need more specialized error classes for your domain:

Domain-Specific Errors for E-commerce

// errors/ecommerce.js
const { AppError } = require('./index');

/**
 * Payment Error (402 Payment Required)
 * Used for payment processing failures
 */
class PaymentError extends AppError {
  constructor(message = 'Payment failed', code = '', provider = '') {
    super(message, 402, 'PAYMENT_ERROR');
    this.code = code;
    this.provider = provider;
  }
  
  toJSON() {
    const json = super.toJSON();
    json.error.code = this.code;
    json.error.provider = this.provider;
    return json;
  }
}

/**
 * Inventory Error (400 Bad Request)
 * Used when items are out of stock
 */
class InventoryError extends AppError {
  constructor(message = 'Inventory issue', productId = '', currentStock = 0) {
    super(message, 400, 'INVENTORY_ERROR');
    this.productId = productId;
    this.currentStock = currentStock;
  }
  
  toJSON() {
    const json = super.toJSON();
    json.error.productId = this.productId;
    json.error.currentStock = this.currentStock;
    return json;
  }
}

/**
 * Shipping Error (400 Bad Request)
 * Used for shipping-related issues
 */
class ShippingError extends AppError {
  constructor(message = 'Shipping error', code = '', address = null) {
    super(message, 400, 'SHIPPING_ERROR');
    this.code = code;
    this.address = address;
  }
  
  toJSON() {
    const json = super.toJSON();
    json.error.code = this.code;
    if (this.address) {
      json.error.address = this.address;
    }
    return json;
  }
}

/**
 * Coupon Error (400 Bad Request)
 * Used for invalid or expired coupons
 */
class CouponError extends AppError {
  constructor(message = 'Coupon error', code = '', couponCode = '') {
    super(message, 400, 'COUPON_ERROR');
    this.code = code;
    this.couponCode = couponCode;
  }
  
  toJSON() {
    const json = super.toJSON();
    json.error.code = this.code;
    json.error.couponCode = this.couponCode;
    return json;
  }
}

module.exports = {
  PaymentError,
  InventoryError,
  ShippingError,
  CouponError
};

Using Domain-Specific Errors

// routes/orders.js
const express = require('express');
const router = express.Router();
const Order = require('../models/Order');
const Product = require('../models/Product');
const { ValidationError } = require('../errors');
const { 
  PaymentError, 
  InventoryError, 
  ShippingError 
} = require('../errors/ecommerce');
const asyncHandler = require('../utils/asyncHandler');

// Create order
router.post('/', asyncHandler(async (req, res) => {
  const { products, shippingAddress, paymentMethod } = req.body;
  
  // Validate input
  if (!products || !Array.isArray(products) || products.length === 0) {
    throw new ValidationError('Products list is required and must not be empty');
  }
  
  if (!shippingAddress) {
    throw new ValidationError('Shipping address is required');
  }
  
  if (!paymentMethod) {
    throw new ValidationError('Payment method is required');
  }
  
  // Check inventory for each product
  for (const item of products) {
    const product = await Product.findById(item.productId);
    
    if (!product) {
      throw new ValidationError(`Product with ID ${item.productId} not found`);
    }
    
    if (product.stock < item.quantity) {
      throw new InventoryError(
        `Not enough stock for ${product.name}`,
        product._id.toString(),
        product.stock
      );
    }
  }
  
  // Validate shipping address
  if (!isValidAddress(shippingAddress)) {
    throw new ShippingError(
      'Invalid shipping address',
      'INVALID_ADDRESS',
      shippingAddress
    );
  }
  
  // Process payment
  try {
    await processPayment(paymentMethod, calculateTotal(products));
  } catch (error) {
    throw new PaymentError(
      'Payment processing failed',
      error.code || 'PAYMENT_FAILED',
      paymentMethod.provider
    );
  }
  
  // Create order
  const order = await Order.create({
    user: req.user._id,
    products,
    shippingAddress,
    paymentMethod,
    total: calculateTotal(products),
    status: 'processing'
  });
  
  // Update inventory
  for (const item of products) {
    await Product.findByIdAndUpdate(item.productId, {
      $inc: { stock: -item.quantity }
    });
  }
  
  res.status(201).json(order);
}));

// Helper functions
function isValidAddress(address) {
  // Address validation logic
  return (
    address &&
    address.street &&
    address.city &&
    address.state &&
    address.zipCode &&
    address.country
  );
}

function calculateTotal(products) {
  // Total calculation logic
  // ...
  return total;
}

async function processPayment(paymentMethod, amount) {
  // Payment processing logic
  // ...
}

module.exports = router;

Testing Custom Error Classes

It's important to test your custom error classes to ensure they work as expected:

Unit Testing Error Classes

// tests/errors.test.js
const { expect } = require('chai');
const { 
  AppError, 
  ValidationError, 
  NotFoundError, 
  AuthenticationError 
} = require('../errors');

describe('Custom Error Classes', () => {
  describe('AppError', () => {
    it('should create a base error with default properties', () => {
      const error = new AppError('Test error');
      
      expect(error).to.be.instanceOf(Error);
      expect(error).to.be.instanceOf(AppError);
      expect(error.name).to.equal('AppError');
      expect(error.message).to.equal('Test error');
      expect(error.statusCode).to.equal(500);
      expect(error.type).to.equal('SERVER_ERROR');
      expect(error.isOperational).to.be.true;
      expect(error.stack).to.exist;
    });
    
    it('should accept custom statusCode and type', () => {
      const error = new AppError('Custom error', 418, 'IM_A_TEAPOT');
      
      expect(error.statusCode).to.equal(418);
      expect(error.type).to.equal('IM_A_TEAPOT');
    });
    
    it('should convert to JSON correctly', () => {
      const error = new AppError('Test error', 500, 'TEST_ERROR');
      const json = error.toJSON();
      
      expect(json).to.have.property('error');
      expect(json.error).to.have.property('type', 'TEST_ERROR');
      expect(json.error).to.have.property('message', 'Test error');
      expect(json.error).to.have.property('statusCode', 500);
      expect(json.error).to.have.property('timestamp');
    });
  });
  
  describe('ValidationError', () => {
    it('should create a validation error with status code 400', () => {
      const invalidFields = [
        { field: 'email', message: 'Invalid email' },
        { field: 'password', message: 'Password too short' }
      ];
      
      const error = new ValidationError('Validation failed', invalidFields);
      
      expect(error).to.be.instanceOf(AppError);
      expect(error).to.be.instanceOf(ValidationError);
      expect(error.statusCode).to.equal(400);
      expect(error.type).to.equal('VALIDATION_ERROR');
      expect(error.invalidFields).to.deep.equal(invalidFields);
    });
    
    it('should include invalidFields in JSON output', () => {
      const invalidFields = [
        { field: 'email', message: 'Invalid email' }
      ];
      
      const error = new ValidationError('Validation failed', invalidFields);
      const json = error.toJSON();
      
      expect(json.error).to.have.property('invalidFields');
      expect(json.error.invalidFields).to.deep.equal(invalidFields);
    });
  });
  
  describe('NotFoundError', () => {
    it('should create a not found error with status code 404', () => {
      const error = new NotFoundError('User');
      
      expect(error).to.be.instanceOf(AppError);
      expect(error.statusCode).to.equal(404);
      expect(error.type).to.equal('NOT_FOUND');
      expect(error.message).to.equal('User not found');
      expect(error.resource).to.equal('User');
    });
  });
  
  describe('AuthenticationError', () => {
    it('should create an authentication error with status code 401', () => {
      const error = new AuthenticationError();
      
      expect(error).to.be.instanceOf(AppError);
      expect(error.statusCode).to.equal(401);
      expect(error.type).to.equal('AUTHENTICATION_ERROR');
      expect(error.message).to.equal('Authentication failed');
    });
    
    it('should accept a custom message', () => {
      const error = new AuthenticationError('Invalid token');
      
      expect(error.message).to.equal('Invalid token');
    });
  });
});

Integration Testing Error Handling

// tests/integration/error-handling.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../../app');
const User = require('../../models/User');

describe('Error Handling Integration Tests', () => {
  // Clean up database before tests
  beforeEach(async () => {
    await User.deleteMany({});
  });
  
  describe('Not Found Errors', () => {
    it('should return 404 with proper error format for non-existent route', async () => {
      const res = await request(app)
        .get('/api/non-existent-route')
        .expect('Content-Type', /json/)
        .expect(404);
      
      expect(res.body).to.have.property('error');
      expect(res.body.error).to.have.property('type', 'NOT_FOUND');
      expect(res.body.error).to.have.property('message', 'Route not found');
      expect(res.body.error).to.have.property('statusCode', 404);
    });
    
    it('should return 404 with proper error format for non-existent user', async () => {
      const res = await request(app)
        .get('/api/users/60a1b3d5c5b4c0a3e8f0b5a1') // Non-existent ID
        .expect('Content-Type', /json/)
        .expect(404);
      
      expect(res.body).to.have.property('error');
      expect(res.body.error).to.have.property('type', 'NOT_FOUND');
      expect(res.body.error).to.have.property('message', 'User not found');
      expect(res.body.error).to.have.property('statusCode', 404);
      expect(res.body.error).to.have.property('resource', 'User');
    });
  });
  
  describe('Validation Errors', () => {
    it('should return 400 with validation errors for invalid user creation', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({
          // Missing required fields
        })
        .expect('Content-Type', /json/)
        .expect(400);
      
      expect(res.body).to.have.property('error');
      expect(res.body.error).to.have.property('type', 'VALIDATION_ERROR');
      expect(res.body.error).to.have.property('invalidFields').that.is.an('array');
      expect(res.body.error.invalidFields).to.have.length.greaterThan(0);
    });
  });
  
  describe('Authentication Errors', () => {
    it('should return 401 for invalid login credentials', async () => {
      // First create a user
      await User.create({
        email: 'test@example.com',
        username: 'testuser',
        password: 'password123'
      });
      
      // Try to login with wrong password
      const res = await request(app)
        .post('/api/users/login')
        .send({
          email: 'test@example.com',
          password: 'wrongpassword'
        })
        .expect('Content-Type', /json/)
        .expect(401);
      
      expect(res.body).to.have.property('error');
      expect(res.body.error).to.have.property('type', 'AUTHENTICATION_ERROR');
      expect(res.body.error).to.have.property('message', 'Invalid email or password');
      expect(res.body.error).to.have.property('statusCode', 401);
    });
  });
});

Best Practices

Here are some best practices for working with custom error classes in Express applications:

flowchart LR A[Custom Error
Best Practices] --> B[Create a Base
Error Class] A --> C[Include HTTP
Status Codes] A --> D[Add Context
Information] A --> E[Distinguish Operational
from Programming Errors] A --> F[Standardize
JSON Format] A --> G[Centralize
Error Handling] A --> H[Test Your
Error Classes]

Error Hierarchy

Error Properties

Error Handling

Code Organization

Practical Exercises

Exercise 1: Basic Error Classes

Create a hierarchy of custom error classes for a RESTful API:

  1. Create a base AppError class that extends Error
  2. Create specialized error classes for common HTTP status codes (400, 401, 403, 404, 409, 500)
  3. Implement a central error handling middleware that formats errors appropriately
  4. Create a small Express application with routes that demonstrate throwing and handling these errors

Test your implementation with both valid and invalid requests.

Exercise 2: Advanced Domain Errors

Build on Exercise 1 to create a more advanced error handling system for a specific domain (e.g., e-commerce, blogging, social media):

  1. Create domain-specific error classes (e.g., PaymentError, InventoryError, etc.)
  2. Implement error factories to create errors consistently
  3. Create utility functions to handle try/catch boilerplate
  4. Implement converter functions for third-party errors (e.g., database errors)
  5. Add unit tests for your error classes

Implement a small API with routes that use these error classes and demonstrate proper error handling.

Additional Resources

Documentation and Articles

Libraries

HTTP Error Standards