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.
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.
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.
You now know how to build expressive custom errors. Next, get an overview of Express authentication strategies in the Authentication lesson.