Custom Error Classes and Advanced Error Handling

Custom error classes are the foundation of a maintainable error handling strategy. When all errors in an application are generic Error objects with only a message string, it is impossible to distinguish “user sent invalid data” from “database is down” from “third-party API rate limited us” โ€” they all look the same to error handlers. A class hierarchy of application-specific errors, with properties for HTTP status codes, error codes, operational vs programmer distinction, and structured context, enables error handlers to respond differently to each type without parsing error messages โ€” a fragile, impossible-to-maintain approach.

Error Classification

Class Caused By HTTP Status Recovery
ValidationError Invalid user input 400 Return specific field errors to user
AuthenticationError Missing or invalid credentials 401 Redirect to login
AuthorizationError Authenticated but not permitted 403 Show access denied message
NotFoundError Resource does not exist 404 Show not found page or empty state
ConflictError Duplicate resource 409 Suggest existing resource or merge
RateLimitError Too many requests 429 Show retry-after time
ExternalServiceError Third-party API failed 503 Retry or degrade gracefully
InternalError Bug / unexpected state 500 Alert on-call, do not expose details
Note: The distinction between operational errors (expected failure conditions: user not found, validation failed, rate limited) and programmer errors (bugs: null dereference, wrong argument type, incorrect logic) is fundamental. Operational errors should be caught, logged at info/warn level, and returned as appropriate HTTP responses. Programmer errors should crash the process (or trigger graceful shutdown) โ€” they indicate the application is in an unknown state that cannot be safely reasoned about.
Tip: Use the cause property (ES2022, supported in Node.js 16.9+) to chain errors: throw new DatabaseError('Failed to save task', { cause: originalMongooseError }). The error chain preserves both the high-level business error and the original technical cause. Logging systems and debugging tools can follow the chain to find the root cause. Avoid discarding the original error โ€” throw new Error('DB failed') loses all context from the Mongoose error.
Warning: Express’s error handling middleware only receives errors passed to next(err) or thrown in async route handlers when wrapped with an async wrapper. With modern Express 5 (or express-async-errors package), thrown errors in async handlers are automatically forwarded to error middleware. In Express 4 without this, an uncaught async error bypasses error middleware entirely and causes an unhandled rejection. Always use express-async-errors or wrap handlers with asyncHandler.

Complete Error Handling System

// src/errors/app-errors.js โ€” custom error class hierarchy

class AppError extends Error {
    constructor(message, options = {}) {
        super(message, { cause: options.cause });   // ES2022 cause chaining
        this.name           = this.constructor.name;
        this.statusCode     = options.statusCode ?? 500;
        this.code           = options.code ?? 'INTERNAL_ERROR';
        this.isOperational  = options.isOperational ?? false;
        this.context        = options.context ?? {};  // extra structured context

        // Capture stack trace excluding the constructor itself
        if (Error.captureStackTrace) {
            Error.captureStackTrace(this, this.constructor);
        }
    }

    toJSON() {
        return {
            name:       this.name,
            message:    this.message,
            code:       this.code,
            statusCode: this.statusCode,
            context:    this.context,
        };
    }
}

class ValidationError extends AppError {
    constructor(message, fields = {}) {
        super(message, { statusCode: 400, code: 'VALIDATION_ERROR', isOperational: true });
        this.fields = fields;  // { fieldName: 'error message' }
    }
}

class AuthenticationError extends AppError {
    constructor(message = 'Authentication required', code = 'AUTH_REQUIRED') {
        super(message, { statusCode: 401, code, isOperational: true });
    }
}

class AuthorizationError extends AppError {
    constructor(message = 'Insufficient permissions') {
        super(message, { statusCode: 403, code: 'FORBIDDEN', isOperational: true });
    }
}

class NotFoundError extends AppError {
    constructor(resource = 'Resource', id = null) {
        super(id ? `${resource} '${id}' not found` : `${resource} not found`,
            { statusCode: 404, code: 'NOT_FOUND', isOperational: true });
        this.resource = resource;
        this.id       = id;
    }
}

class ConflictError extends AppError {
    constructor(message, field = null) {
        super(message, { statusCode: 409, code: 'CONFLICT', isOperational: true });
        this.field = field;
    }
}

class RateLimitError extends AppError {
    constructor(retryAfterSeconds = 60) {
        super(`Rate limit exceeded. Retry after ${retryAfterSeconds} seconds`,
            { statusCode: 429, code: 'RATE_LIMITED', isOperational: true });
        this.retryAfter = retryAfterSeconds;
    }
}

class ExternalServiceError extends AppError {
    constructor(service, message, { cause } = {}) {
        super(`${service} unavailable: ${message}`,
            { statusCode: 503, code: 'EXTERNAL_SERVICE_ERROR', isOperational: true, cause });
        this.service = service;
    }
}

module.exports = {
    AppError, ValidationError, AuthenticationError, AuthorizationError,
    NotFoundError, ConflictError, RateLimitError, ExternalServiceError,
};

// โ”€โ”€ Express global error handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// src/middleware/error.middleware.js
const { AppError } = require('../errors/app-errors');

module.exports = function errorHandler(err, req, res, next) {
    // Convert known framework errors to AppError subclasses
    if (err.name === 'ValidationError' && err.errors) {
        // Mongoose validation error
        const fields = Object.fromEntries(
            Object.entries(err.errors).map(([k, v]) => [k, v.message])
        );
        err = new ValidationError('Validation failed', fields);
    }
    if (err.name === 'CastError') {
        err = new NotFoundError('Resource', req.params.id);
    }
    if (err.code === 11000) {
        const field = Object.keys(err.keyPattern)[0];
        err = new ConflictError(`${field} already in use`, field);
    }

    // Programmer error โ€” unexpected, log at error level
    if (!(err instanceof AppError) || !err.isOperational) {
        logger.error('Programmer error', {
            error:  err.message,
            stack:  err.stack,
            cause:  err.cause?.message,
            method: req.method,
            path:   req.path,
        });

        return res.status(500).json({
            message: process.env.NODE_ENV === 'production'
                ? 'Internal server error'
                : err.message,
        });
    }

    // Operational error โ€” expected, log at warn level
    logger.warn('Operational error', {
        code:    err.code,
        status:  err.statusCode,
        message: err.message,
        context: err.context,
        path:    req.path,
    });

    const response = {
        message: err.message,
        code:    err.code,
    };
    if (err.fields)      response.fields      = err.fields;
    if (err.retryAfter)  response.retryAfter  = err.retryAfter;

    res.status(err.statusCode).json({ success: false, error: response });
};

How It Works

Step 1 โ€” Error.captureStackTrace Produces Clean Stack Traces

Error.captureStackTrace(this, this.constructor) populates this.stack with the V8 stack trace, excluding the constructor call itself. Without this, the stack trace starts with the AppError constructor call rather than the actual throw site โ€” making it harder to locate the problem. The second argument tells V8 where to start the trace, hiding the internal error class infrastructure from developers reading stack traces.

Step 2 โ€” isOperational Distinguishes Expected from Unexpected

Operational errors (isOperational: true) are conditions the application anticipated and can handle gracefully: user not found, validation failed, rate limited. They should not trigger alerts. Programmer errors (isOperational: false or not an AppError at all) indicate a bug and should trigger an on-call alert. The error handler checks this flag to decide between a warn log (operational) and an error log with potential process restart (programmer error).

Step 3 โ€” Error Conversion Normalises Framework Errors

Mongoose, Express, and other libraries throw their own error types. The global error handler converts known framework error types to AppError subclasses: Mongoose ValidationError โ†’ application ValidationError, Mongoose CastError โ†’ NotFoundError, MongoDB 11000 duplicate key โ†’ ConflictError. This normalisation means route handlers and services only need to throw AppError subclasses โ€” framework errors are handled in one place.

Step 4 โ€” Error Cause Chaining Preserves Root Cause

ES2022’s cause option in new Error(message, { cause: originalError }) attaches the original error as a property. When a Mongoose error causes a service-level error, the chain preserves both: the service error describes the business operation that failed, and err.cause shows the technical MongoDB error that caused it. Logging both gives operational teams the full picture without exposing technical details in the API response.

Step 5 โ€” Different Error Types Generate Different Responses

Each AppError subclass carries its own statusCode and any type-specific fields (ValidationError.fields, RateLimitError.retryAfter). The global error handler reads these and constructs the appropriate response. This separation means the business logic (which throws the error) is decoupled from the HTTP presentation (which formats the response) โ€” changing how rate limit errors are presented only requires updating the error handler, not every place that throws RateLimitError.

Quick Reference

Error Type Throw
Validation failed throw new ValidationError('Invalid data', { email: 'Invalid format' })
Not authenticated throw new AuthenticationError()
Not authorised throw new AuthorizationError('Admin only')
Resource missing throw new NotFoundError('Task', taskId)
Duplicate resource throw new ConflictError('Email already in use', 'email')
Rate limited throw new RateLimitError(60)
External service down throw new ExternalServiceError('Stripe', err.message, { cause: err })
Chain cause new AppError('Failed', { cause: originalError })

🧠 Test Yourself

A Mongoose save fails with a duplicate key error (err.code === 11000). What should the global error handler return to the API client, and what should it log?