JavaScript Interview Prep
Error Handling

Custom Errors

Building an Error Hierarchy That Scales

LinkedIn Hook

Your API is returning { "error": "Something went wrong" } for every failure — validation, auth, database crash, payment declined. The frontend has no idea what to render. Support is drowning. Logs are useless.

The fix is not a bigger log. It is a tiny class hierarchy. One AppError base, five or six named subclasses (ValidationError, AuthError, NotFoundError...), and one centralized Express handler that reads instanceof and responds with the right status code, error code, and payload. That is the pattern every serious Node.js backend uses — and the one interviewers probe for when they ask "how do you handle errors in a REST API?"

In this lesson you will learn how to extend Error, why Error.captureStackTrace matters, how to distinguish operational errors from programmer bugs, and how to wire up a production-grade error handler in ~40 lines.

Read the full lesson -> [link]

#JavaScript #InterviewPrep #NodeJS #ExpressJS #ErrorHandling #Backend #CodingInterview #WebDevelopment


Custom Errors thumbnail


What You'll Learn

  • How to extend the built-in Error class with statusCode, errorCode, and isOperational
  • The full AppError hierarchy used in production Node.js/Express backends
  • How to route errors with instanceof, preserve stack traces across wrapped errors, and centralize handling in one middleware

One Hospital, Many Alarms

Imagine you're building a hospital. You wouldn't have a single alarm for every problem — fire, security breach, and equipment failure all need different alarms with different response protocols. Custom errors work the same way: instead of one generic Error, you create specific error types that tell you exactly what went wrong and how to respond.

Extending the Error Class

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.name = "AppError";
    this.statusCode = statusCode;

    // Maintains proper stack trace (V8 engines — Node/Chrome)
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

const error = new AppError("Something failed", 500);
console.log(error.message);    // "Something failed"
console.log(error.statusCode); // 500
console.log(error.name);       // "AppError"
console.log(error.stack);      // Full stack trace
console.log(error instanceof AppError); // true
console.log(error instanceof Error);    // true

Building a Complete Error Hierarchy for APIs

This is a production-ready pattern used in Express.js / Node.js backends:

// Base application error
class AppError extends Error {
  constructor(message, statusCode, errorCode) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.errorCode = errorCode;
    this.isOperational = true; // Distinguish from programmer errors

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

// Validation errors — 400
class ValidationError extends AppError {
  constructor(message, fields = []) {
    super(message, 400, "VALIDATION_ERROR");
    this.fields = fields; // Which fields failed validation
  }
}

// Authentication errors — 401
class AuthError extends AppError {
  constructor(message = "Authentication required") {
    super(message, 401, "AUTH_ERROR");
  }
}

// Authorization errors — 403
class ForbiddenError extends AppError {
  constructor(message = "Access denied") {
    super(message, 403, "FORBIDDEN");
  }
}

// Not found errors — 404
class NotFoundError extends AppError {
  constructor(resource = "Resource") {
    super(`${resource} not found`, 404, "NOT_FOUND");
    this.resource = resource;
  }
}

// Rate limit errors — 429
class RateLimitError extends AppError {
  constructor(retryAfter = 60) {
    super("Too many requests", 429, "RATE_LIMIT");
    this.retryAfter = retryAfter;
  }
}

Using the Error Hierarchy

// In your service layer
function getUserById(id) {
  if (!id) {
    throw new ValidationError("User ID is required", ["id"]);
  }

  const user = database.find(id);
  if (!user) {
    throw new NotFoundError("User");
  }

  return user;
}

// In your auth middleware
function authenticate(token) {
  if (!token) {
    throw new AuthError("No token provided");
  }

  const decoded = verifyToken(token);
  if (!decoded) {
    throw new AuthError("Invalid or expired token");
  }

  return decoded;
}

instanceof Checking with Custom Errors

try {
  getUserById(null);
} catch (error) {
  if (error instanceof ValidationError) {
    console.log("Validation failed:", error.fields);
    // Send 400 response
  } else if (error instanceof NotFoundError) {
    console.log("Not found:", error.resource);
    // Send 404 response
  } else if (error instanceof AuthError) {
    console.log("Auth failed");
    // Send 401 response
  } else if (error instanceof AppError) {
    // Some other operational error
    console.log("App error:", error.statusCode);
  } else {
    // Programmer error — this is a bug
    console.error("Unexpected error:", error);
    // Send 500 response
  }
}

Centralized Error Handler (Express.js Pattern)

// errorHandler.js — Express centralized error middleware
function errorHandler(err, req, res, next) {
  // Log all errors
  console.error(`[${new Date().toISOString()}] ${err.stack}`);

  // Operational errors — send appropriate response
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      status: "error",
      code: err.errorCode,
      message: err.message,
      ...(err.fields && { fields: err.fields }),
      ...(err.retryAfter && { retryAfter: err.retryAfter }),
    });
  }

  // Mongoose validation error -> convert to our ValidationError
  if (err.name === "ValidationError" && err.errors) {
    const fields = Object.keys(err.errors);
    return res.status(400).json({
      status: "error",
      code: "VALIDATION_ERROR",
      message: "Invalid input data",
      fields,
    });
  }

  // Unknown/programmer errors — don't leak details in production
  res.status(500).json({
    status: "error",
    code: "INTERNAL_ERROR",
    message:
      process.env.NODE_ENV === "production"
        ? "Something went wrong"
        : err.message,
  });
}

// Usage in Express
// app.use(errorHandler); // Must be LAST middleware

Preserving Stack Trace

class DatabaseError extends AppError {
  constructor(originalError) {
    super("Database operation failed", 500, "DB_ERROR");
    this.originalError = originalError;
    // Preserve the original stack for debugging
    this.stack = this.stack + "\nCaused by: " + originalError.stack;
  }
}

// Usage
try {
  await db.query("SELECT * FROM users");
} catch (dbErr) {
  throw new DatabaseError(dbErr); // Wraps original error
}

Custom Errors visual 1


Common Mistakes

  • Forgetting super(message) in the subclass constructor — the .message property ends up empty and error.stack shows the wrong line.
  • Skipping Error.captureStackTrace(this, this.constructor) on V8 — the stack trace includes the constructor frame itself, which makes the trace noisier and the "where was this thrown" line harder to find.
  • Treating programmer bugs (TypeError from a typo, null pointer) as operational errors and swallowing them in the middleware — the bug stays hidden, corrupt state lingers, and you never get a stack trace to debug from.

Interview Questions

Q: Why create custom error classes instead of just using new Error()?

Custom errors let you: 1) Add properties like statusCode and errorCode for programmatic handling. 2) Use instanceof to route different errors to different handlers. 3) Build a hierarchy (ValidationError, AuthError) so a single centralized handler can respond appropriately. 4) Distinguish operational errors from programmer bugs.

Q: What is Error.captureStackTrace and why use it?

It's a V8-specific method that creates the .stack property on the error. By passing this.constructor as the second argument, you exclude the error's own constructor from the stack trace, making the trace cleaner — it starts from where the error was thrown, not where it was constructed.

Q: What's the difference between operational errors and programmer errors?

Operational errors are expected failures — invalid user input, network timeouts, resource not found. You handle these gracefully. Programmer errors are bugs — calling a function with wrong arguments, accessing a property of undefined. These indicate code that needs to be fixed, and typically should crash the process (in Node.js) so a process manager can restart it.

Q: How do you create a custom error class?

Extend Error, call super(message) to set the message, assign this.name = this.constructor.name, attach any extra properties you need (statusCode, errorCode, fields), and call Error.captureStackTrace(this, this.constructor) on V8 so the stack starts at the throw site instead of the constructor.

Q: In Express.js, how do you create a centralized error handler?

Define a middleware with the four-argument signature (err, req, res, next) and register it with app.use(errorHandler) as the LAST middleware. Inside, branch on err instanceof AppError to return the appropriate status code, error code, and payload; fall back to a generic 500 for unknown errors, and hide the message in production.


Quick Reference — Cheat Sheet

CUSTOM ERROR HIERARCHY — QUICK MAP

Shape:
  Error
   |
   +-- AppError                (statusCode, errorCode, isOperational)
        |
        +-- ValidationError    400  (fields[])
        +-- AuthError          401
        +-- ForbiddenError     403
        +-- NotFoundError      404  (resource)
        +-- RateLimitError     429  (retryAfter)

Boilerplate:
  class AppError extends Error {
    constructor(msg, status, code) {
      super(msg);
      this.name = this.constructor.name;
      this.statusCode = status;
      this.errorCode  = code;
      this.isOperational = true;
      Error.captureStackTrace?.(this, this.constructor);
    }
  }

Routing:
  if (err instanceof ValidationError) -> 400 + fields
  if (err instanceof AuthError)       -> 401
  if (err instanceof NotFoundError)   -> 404
  if (err instanceof AppError)        -> err.statusCode
  else                                -> 500 (programmer bug)

Express wire-up:
  app.use(errorHandler)  // LAST middleware, (err, req, res, next)

Previous: try...catch...finally Next: Error Types -> The Six Built-ins


This is Lesson 10.2 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.

On this page