Skip to content

Custom Error Classes in Express

Attaching a status property to plain Error objects works, but custom error classes make your intent explicit and your error handling code much easier to read. This lesson builds a small hierarchy of custom errors.

Why Build Custom Error Classes?

A custom error class extends the built-in Error and adds meaningful properties, like status and a machine-readable code, directly in its constructor. This means throwing new NotFoundError('User not found') is both clearer to read and guarantees consistent shape wherever it's caught.

It also lets your centralized error handler distinguish your own well-defined operational errors from unexpected bugs using instanceof checks.

class AppError extends Error {
  constructor(message, status = 500) {
    super(message);
    this.status = status;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(message = 'Resource not found') {
    super(message, 404);
  }
}

module.exports = { AppError, NotFoundError };

isOperational: true marks this as an expected error type your app anticipates, useful for deciding how much detail to log or expose.

A Small Error Class Hierarchy

class AppError extends Error { ... }
class NotFoundError extends AppError { ... }      // 404
class ValidationError extends AppError { ... }     // 400
class UnauthorizedError extends AppError { ... }   // 401
class ForbiddenError extends AppError { ... }       // 403
class ConflictError extends AppError { ... }        // 409
  • Each subclass hardcodes its own default status code, keeping call sites concise.
  • All subclasses inherit isOperational and stack trace behavior from the base AppError.
  • Throwing new ValidationError('Email is required') reads clearly at the call site.
  • The centralized error handler checks err instanceof AppError to decide how to respond.

Custom Error Classes Cheatsheet

A ready-to-use set of error classes for common API scenarios.

Class Status Use Case
ValidationError 400 Invalid or missing input
UnauthorizedError 401 Missing or invalid authentication
ForbiddenError 403 Authenticated but not permitted
NotFoundError 404 Resource does not exist
ConflictError 409 Duplicate or conflicting resource

Using Custom Errors in Services

Custom errors shine in the service layer, business logic can throw a specific, meaningful error without knowing anything about HTTP.

const { NotFoundError, ConflictError } = require('../errors');

exports.createUser = async ({ email }) => {
  if (await User.findOne({ email })) {
    throw new ConflictError('Email already registered');
  }
  return User.create({ email });
};

exports.findById = async (id) => {
  const user = await User.findById(id);
  if (!user) throw new NotFoundError('User not found');
  return user;
};

Handling Custom Errors Centrally

The centralized error handler now has a clean, explicit distinction: known AppError instances are safe to show to clients directly; anything else is treated as an unexpected bug.

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

app.use((err, req, res, next) => {
  if (err instanceof AppError) {
    return res.status(err.status).json({ error: err.message });
  }

  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

Common Mistakes

  • Throwing plain strings instead of Error (or subclass) instances, losing the stack trace.
  • Building a new one-off error shape for every route instead of reusing a small class hierarchy.
  • Forgetting Error.captureStackTrace(), resulting in less useful stack traces for custom classes.
  • Exposing err.message for non-operational errors, potentially leaking internal implementation details.

Key Takeaways

  • Custom error classes attach meaningful status codes and metadata directly to thrown errors.
  • A small hierarchy (AppError and its subclasses) keeps error creation consistent across the app.
  • Services can throw expressive errors without any knowledge of HTTP.
  • instanceof AppError lets the central handler safely distinguish expected errors from bugs.

Pro Tip

Keep your custom error classes in a single errors/ module and import them everywhere, a shared, well-known vocabulary of error types makes error handling predictable across an entire codebase.