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
AppErrorbase, five or six named subclasses (ValidationError,AuthError,NotFoundError...), and one centralized Express handler that readsinstanceofand 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, whyError.captureStackTracematters, 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
What You'll Learn
- How to extend the built-in
Errorclass withstatusCode,errorCode, andisOperational - The full
AppErrorhierarchy 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
}
Common Mistakes
- Forgetting
super(message)in the subclass constructor — the.messageproperty ends up empty anderror.stackshows 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
statusCodeanderrorCodefor programmatic handling. 2) Useinstanceofto 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
.stackproperty on the error. By passingthis.constructoras 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, callsuper(message)to set the message, assignthis.name = this.constructor.name, attach any extra properties you need (statusCode,errorCode,fields), and callError.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 withapp.use(errorHandler)as the LAST middleware. Inside, branch onerr instanceof AppErrorto 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.