Skip to main content

🏗️ 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 Error class to create your own error types
  • Build an AppError base carrying statusCode, type, and isOperational
  • Use Error.captureStackTrace to keep constructors out of the stack trace
  • Derive specific errors — ValidationError, NotFoundError, AuthError — from the base
  • Route by error type with instanceof in 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.

classDiagram Error <|-- TypeError Error <|-- RangeError Error <|-- ReferenceError Error <|-- SyntaxError class Error { +string message +string name +string stack }

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.

Native Error extended into AppError, then into domain-specific error classes Error (native) AppError + statusCode, type ValidationError NotFoundError AuthError extends
One base class centralizes the shared HTTP concerns; each subclass just fixes its own status and type.

The payoff:

  • Classification — check err instanceof NotFoundError instead 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
  • Readabilitythrow new AuthError() documents intent better than a bare Error plus 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.

classDiagram Error <|-- AppError AppError <|-- ValidationError AppError <|-- NotFoundError AppError <|-- AuthError AppError <|-- ConflictError class AppError { +number statusCode +string type +boolean isOperational +toJSON() } class ValidationError { +array invalidFields } class NotFoundError { +string resource }
// 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.name so 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 instanceof or type
  • 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 Error and call super(message)
  • An AppError base carries statusCode, type, and isOperational so every error is HTTP-aware
  • Always set this.name = this.constructor.name and call Error.captureStackTrace
  • Subclasses stay tiny — they just fix a status/type and add their own context
  • A shared toJSON() plus instanceof gives every error a consistent response and clean routing
  • Trust your own AppErrors in the handler; mask everything else in production

📚 Additional Resources

🚀 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.