🏗️ Custom Error Classes
In the last two lessons you kept writing the same three lines: const err = new Error(msg); err.statusCode = 404; throw err;. That works, but it's repetitive and easy to get wrong. What if throwing the right error — with the right status code and the right response shape — were a single, readable line: throw new NotFoundError('User')? That's what a custom error hierarchy gives you.
Week 7 · Day 5 (Friday: Error Handling and Validation) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Extend the native
Errorclass to create your own error types - Build an
AppErrorbase carryingstatusCode,type, andisOperational - Use
Error.captureStackTraceto keep constructors out of the stack trace - Derive specific errors —
ValidationError,NotFoundError,AuthError— from the base - Route by error type with
instanceofin a centralized handler - Serialize errors to consistent JSON with a
toJSON()method
Estimated Time: 60 minutes
Practice: Build an errors/ module with a base class and three subclasses, then wire it to an error handler.
In This Lesson
JavaScript's Error Family Tree
Before building your own, it helps to see that JavaScript already thinks in terms of an error hierarchy. The base Error class is the parent; the built-in types you've hit while debugging — TypeError, RangeError, ReferenceError, SyntaxError — are all its children. Each one is an Error, but with a more specific meaning.
Every error carries three standard properties: message (the human-readable description), name (the type label, like "TypeError"), and stack (the trace of where it happened). Because they share a common ancestor, you can catch broadly and inspect specifically:
try {
const obj = null;
console.log(obj.property); // throws a TypeError
} catch (error) {
console.log(error.name); // "TypeError"
console.log(error.message); // "Cannot read properties of null..."
if (error instanceof TypeError) {
console.log('It is specifically a type error');
}
}
When you create custom error classes, you're simply adding new members to this family — specialized children tailored to your application's needs. And because they extend Error, everything that already works with errors (throwing, catching, instanceof, Express's forwarding) works with yours too.
Why Custom Error Classes?
The generic Error only knows a message. A web API needs more: an HTTP status, a stable machine-readable type, a flag for whether the error is safe to show. Bolting those on by hand every time is exactly the repetition we want to kill.
The payoff:
- Classification — check
err instanceof NotFoundErrorinstead of comparing magic strings - Context — attach domain data (invalid fields, the missing resource name) right on the error
- Consistency — one
toJSON()means every error responds in the same shape - Readability —
throw new AuthError()documents intent better than a bareErrorplus manual status - Status codes — the correct HTTP code travels with the error automatically
Extending the Error Class
Creating a custom error is just an ES2015 class that extends Error and calls super(message) so the base constructor sets up message and stack. Two details make it production-quality.
class CustomError extends Error {
constructor(message) {
super(message); // let Error set message & stack
this.name = this.constructor.name; // "CustomError", not "Error"
// Trim this constructor out of the recorded stack trace so the
// trace points at the THROW site, not the error's own creation.
Error.captureStackTrace(this, this.constructor);
}
}
try {
throw new CustomError('Something specific went wrong');
} catch (err) {
console.log(err.name); // "CustomError"
console.log(err.message); // "Something specific went wrong"
console.log(err instanceof CustomError); // true
console.log(err instanceof Error); // true
}
📖 Two must-do lines
this.name = this.constructor.name — without it, error.name stays "Error", which is misleading in logs. Setting it from this.constructor.name means every subclass reports its own name for free.
Error.captureStackTrace(this, this.constructor) — a V8 feature (Node, Chromium) that omits the error constructor from the stack, so the trace starts where you threw the error. It's silently ignored elsewhere, so it's safe to always include.
The AppError Base Class
Now the centerpiece. AppError extends Error and adds the properties every HTTP error in your app should carry. Every other custom error will extend this, inheriting the HTTP smarts.
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode = 500, type = 'SERVER_ERROR', isOperational = true) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode; // the HTTP status to respond with
this.type = type; // a stable, machine-readable code
this.isOperational = isOperational; // expected error vs a bug?
this.timestamp = new Date().toISOString();
Error.captureStackTrace(this, this.constructor);
}
// A single, consistent JSON shape for API responses.
toJSON() {
return {
error: {
type: this.type,
message: this.message,
statusCode: this.statusCode,
timestamp: this.timestamp
}
};
}
}
module.exports = AppError;
💡 The isOperational flag, formalized
Recall the operational-vs-programmer split from the middleware lesson. Here it becomes a real property. Errors you construct on purpose default to isOperational = true — safe to describe to clients. An unexpected TypeError is not an AppError, so it won't have the flag, and your handler will mask it in production. The class system encodes the distinction for you.
The type string (like 'NOT_FOUND') is worth calling out: unlike a status code, it's stable and specific, so client apps can branch on it (if (error.type === 'VALIDATION_ERROR')) without parsing messages that might be reworded later.
A Hierarchy of API Errors
With the base in place, each specific error becomes tiny — it just calls super with the right status and type, and optionally adds its own context and an extended toJSON.
// errors/index.js
const AppError = require('./AppError');
// 400 — input failed validation. Carries the offending fields.
class ValidationError extends AppError {
constructor(message = 'Validation failed', invalidFields = []) {
super(message, 400, 'VALIDATION_ERROR');
this.invalidFields = invalidFields;
}
toJSON() {
const json = super.toJSON();
json.error.invalidFields = this.invalidFields;
return json;
}
}
// 404 — a resource the client asked for doesn't exist.
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND');
this.resource = resource;
}
toJSON() {
const json = super.toJSON();
json.error.resource = this.resource;
return json;
}
}
// 401 — who are you? Authentication failed.
class AuthError extends AppError {
constructor(message = 'Authentication failed') {
super(message, 401, 'AUTH_ERROR');
}
}
// 409 — the request conflicts with existing state (e.g. duplicate email).
class ConflictError extends AppError {
constructor(message = 'Resource conflict', field = '') {
super(message, 409, 'CONFLICT_ERROR');
this.field = field;
}
toJSON() {
const json = super.toJSON();
if (this.field) json.error.field = this.field;
return json;
}
}
module.exports = { AppError, ValidationError, NotFoundError, AuthError, ConflictError };
Each subclass is only a few lines because all the heavy lifting — stack capture, name, timestamp, base JSON — lives in AppError. That's the whole point of inheritance: define the shared behavior once.
Throwing & Handling by Type
In your routes, throwing is now expressive and terse. Combined with the asyncHandler wrapper from the first lesson, there's no try/catch clutter either.
// routes/users.js
const express = require('express');
const router = express.Router();
const User = require('../models/User');
const asyncHandler = require('../utils/asyncHandler');
const { NotFoundError, ConflictError, AuthError } = require('../errors');
// GET one user
router.get('/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User'); // ← one clean line
res.json(user);
}));
// POST create user
router.post('/', asyncHandler(async (req, res) => {
const { email, username, password } = req.body;
if (await User.findOne({ email })) {
throw new ConflictError('Email already in use', 'email');
}
const user = await User.create({ email, username, password });
res.status(201).json(user);
}));
// POST login
router.post('/login', asyncHandler(async (req, res) => {
const user = await User.findOne({ email: req.body.email });
// Same message whether the email or password is wrong — don't leak which.
if (!user || !(await user.comparePassword(req.body.password))) {
throw new AuthError('Invalid email or password');
}
res.json({ token: user.generateAuthToken() });
}));
module.exports = router;
Now the centralized error handler can lean on instanceof and toJSON(). Notice how compact it becomes — the errors carry their own status, shape, and safety flag.
// middleware/errorHandler.js
const { AppError } = require('../errors');
module.exports = (err, req, res, next) => {
const isProd = process.env.NODE_ENV === 'production';
console.error(err); // always log server-side
// Our own AppError instances are trusted and self-describing.
if (err instanceof AppError) {
const body = err.toJSON();
if (!isProd) body.error.stack = err.stack;
return res.status(err.statusCode).json(body);
}
// Anything else is an unexpected programmer error: mask it in prod.
res.status(500).json({
error: {
type: 'SERVER_ERROR',
message: isProd ? 'Something went wrong. Please try again later.' : err.message,
...(!isProd && { stack: err.stack })
}
});
};
Output — throw new NotFoundError('User')
HTTP 404
{
"error": {
"type": "NOT_FOUND",
"message": "User not found",
"statusCode": 404,
"timestamp": "2026-07-31T10:15:00.000Z",
"resource": "User"
}
}
You can also convert third-party errors (a Mongoose ValidationError, a duplicate-key error) into your own classes in one place, so the rest of your app only ever sees your error types. Route validation failures from the previous lesson slot in the same way: throw a ValidationError with the collected fields.
Practice & Quiz
🏋️ Exercise 1: Build a RateLimitError
Goal: Add a RateLimitError to the hierarchy. It should be a 429 with type 'RATE_LIMIT', accept a retryAfter (seconds), and include retryAfter in its JSON.
💡 Hint
Extend AppError, call super(message, 429, 'RATE_LIMIT'), store this.retryAfter, then override toJSON() to call super.toJSON() and attach the field.
✅ Solution
class RateLimitError extends AppError {
constructor(message = 'Too many requests', retryAfter = 60) {
super(message, 429, 'RATE_LIMIT');
this.retryAfter = retryAfter;
}
toJSON() {
const json = super.toJSON();
json.error.retryAfter = this.retryAfter;
return json;
}
}
// Usage:
throw new RateLimitError('Slow down', 30);
// → 429, { error: { type: 'RATE_LIMIT', retryAfter: 30, ... } }
🏋️ Exercise 2: Why this.constructor.name?
Goal: A teammate hardcoded this.name = 'AppError' in the base constructor. Explain what goes wrong for a NotFoundError, and give the one-line fix.
✅ Solution
With a hardcoded 'AppError', every subclass — including NotFoundError — reports error.name === 'AppError', so logs and clients can't tell them apart. Using this.name = this.constructor.name makes each instance report its actual class name ('NotFoundError') automatically, because this.constructor resolves to the real subclass at construction time.
🎯 Quick Quiz
Question 1: Why must a custom error's constructor call super(message)?
Question 2: What does Error.captureStackTrace(this, this.constructor) accomplish?
Question 3: In the centralized handler, why check err instanceof AppError?
Best Practices & Pitfalls
✅ Do
- Put all error classes in one
errors/module and import from there - Give the base class the shared HTTP concerns; keep subclasses tiny
- Set
this.name = this.constructor.nameso each type reports itself - Attach domain context (
invalidFields,resource) right on the error - Convert third-party/library errors into your own types at one boundary
❌ Don't
- Create dozens of classes — cover the common HTTP cases and stop
- Forget
super(message)— the error will be missing its message and stack - Put secrets (raw DB errors, tokens) in
toJSON()— it's client-facing - Compare error messages to branch logic — use
instanceofortype - Mark genuine bugs as
isOperational— that would leak their details in prod
⚠️ instanceof across module boundaries
If two copies of your errors module get loaded (rare, but possible with duplicated dependencies or bundlers), an error created by one copy won't be instanceof the class from the other. Import your error classes from a single shared path everywhere, and prefer checking the stable err.type string as a robust fallback.
Summary
🎉 Key Takeaways
- Custom errors are just classes that
extends Errorand callsuper(message) - An
AppErrorbase carriesstatusCode,type, andisOperationalso every error is HTTP-aware - Always set
this.name = this.constructor.nameand callError.captureStackTrace - Subclasses stay tiny — they just fix a status/type and add their own context
- A shared
toJSON()plusinstanceofgives every error a consistent response and clean routing - Trust your own
AppErrors in the handler; mask everything else in production
📚 Additional Resources
- MDN — Error object & custom errors
- MDN — extends keyword
- Node.js — Error.captureStackTrace
- Express — Error handling guide
🚀 What's Next?
You now have the full error-handling toolkit: centralized middleware, express-validator, and a clean custom error hierarchy. Time to put it all to work. The next lesson is the weekend project — Build a RESTful API for a task management system — where error handling, validation, and custom errors come together in a real, complete application.
🎉 Errors are now first-class citizens
Throwing the right error is a single readable line, every response has the same shape, and your handler stays tiny. That's the professional error architecture behind serious Express APIs.