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 passesinstanceof Errorchecks. - 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 asthis.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 ofError: ...). - Custom properties (like
this.codeabove) — 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
.stackstring, omitting frames fromconstructorOptand 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)setsthis.messageand 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)rewritesthis.stackso the trace starts at the caller ofvalidateAge, not inside theValidationErrorconstructor. - 4. The
throwstatement unwinds the call stack synchronously (or rejects the enclosing promise, if inside anasyncfunction) until a matchingtry/catchis found. - 5. In the
catchblock,err instanceof ValidationErrorwalks 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
Errorsubclasses, never plain strings or objects — only realErrorinstances get a proper.stackand work withinstanceof. - Call
super(message, options)as the first statement in every custom error constructor. - Set
this.name(or usethis.constructor.namein a shared base class) so logs and stack traces show the specific error type, not justError. - Build a small hierarchy — one
AppErrorbase 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
isOperationalflag; log and recover from the former, but let the latter crash loudly so it gets fixed. - Use the
causeoption when wrapping a lower-level error so the original failure is never lost. - Never expose raw
.stacktraces 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 mapsstatusCodeto 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
DatabaseErrorclass extendingAppErrorwith an extraqueryproperty. Write a mockrunQuery(sql)function that throws aDatabaseErrorwhensqlis 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
UploadErrorbase class, plusFileTooLargeError(statusCode 413) andUnsupportedTypeError(statusCode 415) subclasses. Write a functionvalidateUpload(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 customInvalidInputErrorwith areasonproperty instead. Update the calling code to useinstanceofrather than checkingerr.message.
Summary
- Custom error classes extend the built-in
Errorclass to create typed, structured failures instead of generic strings. - Always call
super(message, options)first, setthis.name, and attach structured properties likestatusCodeorfield. Error.captureStackTrace(this, ConstructorName)keeps stack traces clean by excluding the constructor itself.- Use
instanceofto branch on error type reliably — never parseerr.messagestrings. - Build a small hierarchy (a shared
AppErrorbase plus specific subclasses) so callers can catch as broadly or narrowly as they need. - Use the
causeoption (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.
