Skip to content
Express.js ex errors 4 min read

Custom Error Classes

The built-in Error object carries only a message and a stack trace, which forces you to attach err.status by hand in every route and guess whether a failure is a known business condition or an unexpected bug. A small hierarchy of custom error classes fixes this: an AppError base that extends Error adds a statusCode and an isOperational flag, so your error middleware can respond with the right HTTP status and decide what to expose to clients — all without scattering ad-hoc logic across handlers.

Why extend Error

An operational error is an expected, recoverable condition you can describe to the caller — a missing record, a validation failure, an unauthorized request. A programmer error is a bug: a TypeError, a null dereference, a failed assumption. The two demand different treatment. Operational errors should produce a clean, specific response; programmer errors should be logged loudly and answered with a generic 500 so internals never leak.

Encoding this distinction on the error object itself means your centralized handler stays simple. It reads statusCode and isOperational rather than inspecting messages or maintaining a lookup table.

Building the AppError base class

Extend the native Error, accept a status code, mark the error as operational, and call Error.captureStackTrace so the constructor frame is omitted from the trace.

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);

    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true; // a known, expected error — safe to surface

    // Keep this constructor out of the stack trace
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;

Because AppError extends Error, instances still satisfy instanceof Error, work with try/catch, and serialize their stack normally. Setting isOperational = true lets the error handler trust that the message is client-safe.

Specialized subclasses

Subclass AppError for the statuses you use most. Each subclass fixes its status code, so call sites read like plain English and never repeat a magic number.

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

class ValidationError extends AppError {
  constructor(message = 'Invalid request data') {
    super(message, 400);
  }
}

class UnauthorizedError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401);
  }
}

module.exports = { AppError, NotFoundError, ValidationError, UnauthorizedError };

Throwing in route handlers

With the classes in place, route logic becomes declarative. Throw the error that fits the situation; the centralized handler turns it into a response. In an async handler under Express 5 a thrown error is auto-forwarded — under Express 4 you must catch and call next(err) (see async error handling).

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

const router = express.Router();

router.get('/users/:id', async (req, res) => {
  if (!/^\d+$/.test(req.params.id)) {
    throw new ValidationError('User id must be numeric');
  }

  const user = await db.findUser(req.params.id);
  if (!user) {
    throw new NotFoundError(`No user with id ${req.params.id}`);
  }

  res.json(user);
});

module.exports = router;

Consuming them in error middleware

The four-argument handler reads statusCode and checks isOperational. Operational errors return their real message; anything else (a raw TypeError, a third-party failure) is treated as a programmer error and gets a generic 500.

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

app.use((err, req, res, next) => {
  if (res.headersSent) return next(err);

  const isOperational = err instanceof AppError && err.isOperational;
  const statusCode = isOperational ? err.statusCode : 500;

  // Always log the full error server-side
  console.error(err);

  res.status(statusCode).json({
    status: err.status || 'error',
    message: isOperational ? err.message : 'Internal Server Error',
  });
});

Output:

HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8

{"status":"fail","message":"No user with id 999"}

A thrown ValidationError produces a 400, an unhandled TypeError produces a safe 500 — without changing a line of the handler.

Operational vs programmer errors

AspectOperational errorProgrammer error
ExampleNotFoundError, ValidationErrorTypeError, undefined access
instanceof AppErrortruefalse
isOperationaltrueabsent / false
Client responseSpecific message + real statusGeneric 500
ActionRespond cleanlyLog loudly, alert, possibly restart

Tip: Keep isOperational as the single source of truth for “is this safe to show the user?”. Never branch on err.message text — messages change, flags do not.

Warning: Do not mark validation of external input as a programmer error. A malformed request body is an expected, operational 400, not a bug in your code.

TypeScript note

In TypeScript, extending Error requires super(message) before assigning fields, and you may need Object.setPrototypeOf(this, new.target.prototype) when targeting ES5 so instanceof works. Targeting ES2015+ avoids that workaround entirely.

Best Practices

  • Define one AppError base extending Error, with statusCode and isOperational, and derive specific subclasses from it.
  • Call Error.captureStackTrace(this, this.constructor) so your constructor frame is excluded from traces.
  • Mark known, expected failures as isOperational = true; leave unexpected bugs unmarked so they collapse to a generic 500.
  • Throw the most specific subclass (NotFoundError, ValidationError) so call sites read clearly and never hardcode status numbers.
  • In the error handler, branch on isOperational, never on the message text, to decide what to expose.
  • Always log the full error server-side even when you return a sanitized response to the client.
  • Export the classes from a single module so routes and middleware share one definition.
Last updated June 14, 2026
Was this helpful?