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
| Aspect | Operational error | Programmer error |
|---|---|---|
| Example | NotFoundError, ValidationError | TypeError, undefined access |
instanceof AppError | true | false |
isOperational | true | absent / false |
| Client response | Specific message + real status | Generic 500 |
| Action | Respond cleanly | Log loudly, alert, possibly restart |
Tip: Keep
isOperationalas the single source of truth for “is this safe to show the user?”. Never branch onerr.messagetext — 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
AppErrorbase extendingError, withstatusCodeandisOperational, 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 generic500. - 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.