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 |
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.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.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 }) |