JavaScript Custom Errors and Error Cause, Explained
JavaScript custom error classes and the Error cause option let you throw specific, chainable errors. How to define them, wrap failures, and handle them well.
Custom error classes and the cause option are the two JavaScript features that turn error handling from string matching into something structured. A custom error class extends the built-in Error so callers can tell failures apart with instanceof and read extra fields like an HTTP status. The cause option, passed as new Error(message, { cause }), lets you wrap a low-level error in a higher-level one without losing the original. Used together, they give you errors that are both specific and traceable all the way down to the root failure.
The problem with plain errors
Most code starts out throwing generic errors:
throw new Error("User not found");
The caller then has two bad options for deciding what to do. It can compare the message string, which breaks the moment someone fixes a typo or translates the text, or it can treat every failure the same way, which means showing a 500 page for what should have been a 404.
Wrapping causes a second problem. When a database call fails inside a repository function, the natural instinct is to rethrow something more meaningful:
try {
await db.query(sql);
} catch (err) {
throw new Error("Failed to load user");
}
That message is better for the caller, but the original error, with its stack trace and driver-specific details, is gone. Whoever debugs this later sees “Failed to load user” and nothing about why.
Defining a custom error class
A custom error is just a class that extends Error:
class NotFoundError extends Error {
constructor(resource, id, options) {
super(`${resource} ${id} not found`, options);
this.name = "NotFoundError";
this.resource = resource;
this.id = id;
}
}
A few details matter:
- Call
superwith the message. That setsmessageand captures the stack trace. - Set
name. The default is"Error", which makes logs and stack traces less useful. Setting it to the class name gives youNotFoundError: User 42 not foundin the output. - Forward
options. Passing the second argument through tosuperis what makes thecauseoption work for your class too. - Add domain fields. Properties like
resource,id, orstatuslet handlers act on the error without parsing its message.
Callers can now branch reliably:
try {
const user = await getUser(id);
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message });
}
throw err;
}
Rethrowing anything you do not recognize is important. Catching every error and handling only some of them silently swallows bugs.
A small error hierarchy
For larger applications, a shallow hierarchy keeps handling code tidy:
class AppError extends Error {
constructor(message, options) {
super(message, options);
this.name = new.target.name;
}
}
class NotFoundError extends AppError {}
class ValidationError extends AppError {}
class UpstreamError extends AppError {}
Using new.target.name sets name automatically to the subclass’s name, so each subclass does not need its own constructor. A top-level handler can check instanceof AppError to separate expected application failures from genuine bugs like a TypeError.
Keep it shallow. Two levels is usually plenty; deep inheritance trees of errors become just as hard to maintain as deep trees of anything else.
Chaining errors with cause
The cause option, available in all modern browsers and current Node.js releases, solves the lost-context problem:
try {
await db.query(sql);
} catch (err) {
throw new UpstreamError("Failed to load user", { cause: err });
}
The new error carries a clear, high-level message, and err.cause holds the original database error with its own stack trace intact. Errors can be chained several levels deep, and you can walk the chain when logging:
function describe(err) {
const parts = [];
for (let e = err; e; e = e.cause) {
parts.push(`${e.name}: ${e.message}`);
}
return parts.join("\n caused by ");
}
Node.js prints the cause chain automatically for uncaught errors, and browser devtools display it when you log the error object. That alone is a large improvement over losing context at every layer.
cause can hold any value, not just an Error, which is handy when a library rejects with a plain object. Still, wrapping actual errors is the norm, and it is what logging tools expect.
Custom errors with async code
Custom errors work the same way in promise chains and async functions. A rejected promise carries whatever value was thrown, so instanceof checks behave normally inside a catch block after await. If you need a refresher on how rejections propagate, see async/await vs promises.
When running operations in parallel, Promise.allSettled returns each rejection’s reason, and those reasons are your custom error instances, so you can classify every failure individually. Promise.any rejects with an AggregateError whose errors array holds each underlying failure. The trade-offs among the combinators are covered in Promise.all vs allSettled vs race.
Cancellation follows the same pattern. Aborting a request through an AbortController rejects with a DOMException named "AbortError", which is worth checking for explicitly so a deliberate cancellation is not logged as a failure.
Typing custom errors in TypeScript
In TypeScript, the variable in a catch clause is typed unknown under strict settings, which forces you to narrow it before use. Custom classes make that narrowing natural:
class HttpError extends Error {
constructor(
public status: number,
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = "HttpError";
}
}
try {
await fetchUser(id);
} catch (err: unknown) {
if (err instanceof HttpError && err.status === 404) {
return null;
}
throw err;
}
Some teams prefer returning result objects instead of throwing for expected failures, modeling success and failure as a discriminated union. That approach makes failures visible in function signatures. Throwing custom errors remains the better fit for unexpected failures and for code that crosses many layers.
Common mistakes
- Forgetting to set
name. Every error shows up as a genericErrorin logs. - Dropping the cause. Wrapping without
{ cause: err }reintroduces the lost-context problem the feature exists to solve. - Catching too broadly. A
catchthat handles every error the same way hides real bugs. Handle what you recognize and rethrow the rest. - Leaking internals to users. A cause chain is for logs. Send users a safe, high-level message, never the raw database or upstream error.
- Throwing non-errors. Throwing strings or plain objects loses the stack trace. Always throw
Errorinstances or subclasses.
The takeaway
Extend Error to create a small set of meaningful error classes, set their name, give them the fields handlers need, and forward the options argument to super. When one layer catches a lower-level failure, wrap it with { cause: err } so the original error and its stack trace travel with the new one. Callers can then branch on instanceof instead of message strings, and anyone reading the logs can follow the chain straight to the root cause.
Keep reading
Takina · · 4 min read Structural Typing vs Nominal Typing in TypeScript
Structural typing checks shape, not name — TypeScript treats two differently-named types as compatible if their members match, unlike nominal systems.
Takina · · 5 min read TypeScript Variance Explained
Variance decides when TypeScript accepts Array<Dog> where Array<Animal> is expected — covariant, contravariant, or invariant, explained with examples.
Takina · · 5 min read Zod vs Yup: TypeScript Schema Validation Compared
Zod infers static TypeScript types directly from its schemas; Yup was built for JavaScript form validation first. How the two approaches differ.