Custom Error Classes

Every Node.js program eventually fails in some way: a file is missing, a network request times out, a user submits bad data. The built-in Error object gives you a message and a stack trace, but it doesn’t tell you what kind of failure happened or what your code should do about it. Custom error classes fix that by turning errors into typed, structured objects that carry exactly the information your program needs to recover, log, or report the problem correctly.

In this lesson you’ll learn how to extend the built-in Error class, build a reusable error hierarchy, attach machine-readable metadata like status codes, preserve the original cause when wrapping errors, and avoid the mistakes that quietly break stack traces and error handling in real Node.js applications.

Overview: why custom errors matter

Node.js already ships with several built-in error types — Error, TypeError, RangeError, SyntaxError, ReferenceError, and a few others. They’re all subclasses of the base Error class:

Built-in error Typically thrown when
Error Generic failure; the base class for everything else
TypeError A value is not of the expected type (e.g. calling a non-function)
RangeError A value is outside an allowed range (e.g. invalid array length)
SyntaxError Malformed code or malformed data passed to JSON.parse
ReferenceError A reference to an undeclared variable

These are fine for bugs in your own code, but they’re too generic for the errors your application domain produces. If a user submits an invalid age, or a database query fails, or an uploaded file is too large, throwing a plain Error with a string message forces every caller to inspect err.message with fragile string matching to figure out what actually went wrong. A custom error class solves this by giving each failure mode its own type, so callers can use instanceof to branch reliably, and by letting you attach structured data — a status code, a field name, an error code — directly on the object instead of embedding it in a sentence.

Under the hood, a custom error class is just a JavaScript class that extends Error. When you call super(message), the parent Error constructor sets this.message and, critically, captures the current call stack into this.stack. In V8 (Node’s JavaScript engine) you can also call Error.captureStackTrace(this, ConstructorName) to trim the constructor itself out of that stack trace, so the trace starts at the line that actually threw the error instead of inside your class’s constructor.

Syntax

The general shape of a custom error class looks like this:

class CustomError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = "CustomError";
    this.code = "CUSTOM_ERROR";
    Error.captureStackTrace(this, CustomError);
  }
}
  • extends Error — makes your class a real error type: it inherits message, stack, and passes instanceof Error checks.
  • super(message, options) — must be called before you use this. The optional second argument can be { cause: originalError } (Node.js 16.9+), which Node stores as this.cause.
  • this.name — by default every error inherits name = "Error" from the prototype chain. Overriding it makes logs and stack traces show the real type (e.g. ValidationError: ... instead of Error: ...).
  • Custom properties (like this.code above) — structured, machine-readable data callers can branch on without parsing the message string.
  • Error.captureStackTrace(target, constructorOpt) — a V8/Node-specific API that builds a clean .stack string, omitting frames from constructorOpt and anything above it.

Examples

Example 1: A single custom error with a field

Start simple: a ValidationError that carries which field failed validation.

class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
    Error.captureStackTrace(this, ValidationError);
  }
}

function validateAge(age) {
  if (typeof age !== "number" || age < 0) {
    throw new ValidationError("Age must be a non-negative number", "age");
  }
  return age;
}

try {
  validateAge(-5);
} catch (err) {
  if (err instanceof ValidationError) {
    console.log(`${err.name}: ${err.message} (field: ${err.field})`);
  } else {
    throw err;
  }
}

Output:

ValidationError: Age must be a non-negative number (field: age)

Because ValidationError extends Error, it still has a real .stack and passes err instanceof Error, but the caller can also check err instanceof ValidationError and read err.field directly, with no string parsing involved.

Example 2: An error hierarchy with status codes

Real applications usually need several related error types. Build a shared base class, then extend it for each specific case.

class AppError extends Error {
  constructor(message, statusCode = 500, isOperational = true) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, 404);
  }
}

class UnauthorizedError extends AppError {
  constructor(message = "Not authorized") {
    super(message, 401);
  }
}

function findUser(id) {
  const users = { 1: "Ada" };
  if (!users[id]) {
    throw new NotFoundError(`User with id ${id}`);
  }
  return users[id];
}

try {
  findUser(99);
} catch (err) {
  if (err instanceof AppError) {
    console.log(`[${err.statusCode}] ${err.name}: ${err.message}`);
  } else {
    throw err;
  }
}

Output:

[404] NotFoundError: User with id 99

this.constructor.name automatically picks up the correct subclass name (NotFoundError, UnauthorizedError, …), so you don’t have to repeat this.name = "..." in every subclass. The isOperational flag marks this as an expected failure (a missing resource) rather than a bug — a distinction covered more in Best Practices below.

Example 3: Preserving the original error with cause

Since Node.js 16.9, the Error constructor accepts an options object with a cause property, letting you wrap a low-level error in a higher-level, more meaningful one without losing the original.

import { readFile } from "node:fs/promises";

class ConfigLoadError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = "ConfigLoadError";
  }
}

async function loadConfig(path) {
  try {
    const raw = await readFile(path, "utf8");
    return JSON.parse(raw);
  } catch (err) {
    throw new ConfigLoadError(`Failed to load config from ${path}`, { cause: err });
  }
}

async function main() {
  try {
    await loadConfig("./config.json");
  } catch (err) {
    console.log(err.message);
    console.log("Caused by:", err.cause.message);
  }
}

main();

Output (if config.json is missing):

Failed to load config from ./config.json
Caused by: ENOENT: no such file or directory, open './config.json'

Without cause, catching the readFile error and throwing a brand-new Error would silently discard the original ENOENT details. With it, err.cause keeps the full original error — including its own .stack — available for logging while the caller still gets a clear, domain-specific message.

How it works step by step

  • 1. throw new ValidationError(...) runs the constructor: super(message) sets this.message and captures a full stack trace before anything else executes.
  • 2. The remaining constructor lines run synchronously, attaching this.name, this.field, and any other custom properties to the instance.
  • 3. Error.captureStackTrace(this, ValidationError) rewrites this.stack so the trace starts at the caller of validateAge, not inside the ValidationError constructor.
  • 4. The throw statement unwinds the call stack synchronously (or rejects the enclosing promise, if inside an async function) until a matching try/catch is found.
  • 5. In the catch block, err instanceof ValidationError walks the prototype chain (ValidationError.prototype → Error.prototype) to confirm the type, letting you branch with certainty instead of guessing from the message text.

Common Mistakes

Mistake 1: Using this before calling super()

In a derived class, this isn’t initialized until super() runs. Accessing it earlier throws a ReferenceError at runtime.

class ValidationError extends Error {
  constructor(message, field) {
    this.field = field; // ReferenceError: must call super() before 'this'
    super(message);
  }
}

Fix: always call super(message) as the very first line of the constructor, then set additional properties afterward.

class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
  }
}

Mistake 2: Losing the original error when wrapping

Catching a low-level error and re-throwing a new one built from a concatenated string discards the original stack trace and type, making the real cause of a bug much harder to find in production logs.

try {
  await loadConfig("./config.json");
} catch (err) {
  throw new Error("Config failed: " + err.message); // original error is gone
}

Fix: pass the original error as cause so it stays attached to the new error.

try {
  await loadConfig("./config.json");
} catch (err) {
  throw new ConfigLoadError("Config failed", { cause: err });
}

Mistake 3: Branching on err.message instead of the error type

Matching on message text is fragile — it breaks the moment wording changes, and it doesn’t work across locales or library versions.

try {
  findUser(99);
} catch (err) {
  if (err.message.includes("not found")) {
    // breaks silently if the message wording ever changes
  }
}

Fix: check the type with instanceof, and read structured properties instead of parsing text.

try {
  findUser(99);
} catch (err) {
  if (err instanceof NotFoundError) {
    console.log(err.statusCode, err.message);
  }
}

Best Practices

  • Always throw Error subclasses, never plain strings or objects — only real Error instances get a proper .stack and work with instanceof.
  • Call super(message, options) as the first statement in every custom error constructor.
  • Set this.name (or use this.constructor.name in a shared base class) so logs and stack traces show the specific error type, not just Error.
  • Build a small hierarchy — one AppError base class with specific subclasses — so code can catch broadly (instanceof AppError) or narrowly (instanceof NotFoundError) as needed.
  • Attach structured, machine-readable data (statusCode, code, field) instead of encoding it into the message string.
  • Distinguish operational errors (expected failures like bad input or a missing record) from programmer errors (bugs) with an isOperational flag; log and recover from the former, but let the latter crash loudly so it gets fixed.
  • Use the cause option when wrapping a lower-level error so the original failure is never lost.
  • Never expose raw .stack traces or internal error details to end users or API clients — log them server-side and return a safe, generic message instead.
  • In an HTTP server, funnel all thrown AppErrors through one central error-handling middleware that maps statusCode to the response, instead of formatting error responses in every route.

A typical Express error-handling middleware built on the hierarchy above looks like this:

function errorHandler(err, req, res, next) {
  if (err instanceof AppError && err.isOperational) {
    return res.status(err.statusCode).json({ error: err.message });
  }
  console.error(err);
  return res.status(500).json({ error: "Internal server error" });
}

app.use(errorHandler);

Practice Exercises

  • Exercise 1: Create a DatabaseError class extending AppError with an extra query property. Write a mock runQuery(sql) function that throws a DatabaseError when sql is an empty string, then catch it and log both the message and the failing query separately.
  • Exercise 2: Build an error hierarchy for a file-upload service: an UploadError base class, plus FileTooLargeError (statusCode 413) and UnsupportedTypeError (statusCode 415) subclasses. Write a function validateUpload(sizeInBytes, extension) that throws the correct subclass depending on which rule is violated.
  • Exercise 3: Take a function that currently throws new Error("Invalid input: " + reason) and refactor it to throw a custom InvalidInputError with a reason property instead. Update the calling code to use instanceof rather than checking err.message.

Summary

  • Custom error classes extend the built-in Error class to create typed, structured failures instead of generic strings.
  • Always call super(message, options) first, set this.name, and attach structured properties like statusCode or field.
  • Error.captureStackTrace(this, ConstructorName) keeps stack traces clean by excluding the constructor itself.
  • Use instanceof to branch on error type reliably — never parse err.message strings.
  • Build a small hierarchy (a shared AppError base plus specific subclasses) so callers can catch as broadly or narrowly as they need.
  • Use the cause option (Node.js 16.9+) whenever you wrap a lower-level error, so the original failure is never lost.
  • Separate operational errors (expected, recoverable) from programmer errors (bugs) with a flag like isOperational, and handle each differently.