Custom Error Classes You Can Actually Catch
Stop checking error messages with includes(). Extend Error, set name, add the fields the handler needs, catch with instanceof, and wrap low-level errors with cause so the real reason isn't lost.
Make your own error class when the code that catches an error needs to tell it apart from other errors. Extend Error, set name, add the fields the handler needs, and catch it with instanceof. That's about ten lines, and it replaces fragile checks like if (error.message.includes("not found")).
Skip ahead: Already have the class? Skip to "Wrapping errors with cause".
Why message checks break
try {
await placeOrder(cart);
} catch (error) {
if (error.message === "Out of stock") showStockWarning();
else throw error;
}
It works until someone changes the message to "Item is out of stock", or translates it. Then the warning never shows, and an expected situation turns into an unhandled error. The message is for humans. Code should check something that won't change: the class.
The basic class
class OutOfStockError extends Error {
constructor(sku, requested, available) {
super(`Only ${available} of ${sku} left, ${requested} requested`);
this.name = "OutOfStockError";
this.sku = sku;
this.requested = requested;
this.available = available;
}
}
function reserve(sku, qty, stock) {
if (qty > stock[sku]) throw new OutOfStockError(sku, qty, stock[sku]);
stock[sku] -= qty;
}
const stock = { COF: 3 };
try {
reserve("COF", 5, stock);
} catch (error) {
console.log(error instanceof OutOfStockError, error instanceof Error);
console.log(error.name, "|", error.message);
console.log(`Offer ${error.available} instead`);
}
true true
OutOfStockError | Only 3 of COF left, 5 requested
Offer 3 instead
extends Errorgives youmessageand a stack trace, andinstanceof Errorstays true.super(message)must run before you touchthis.this.nameis what logs and the console print in front of the message. Without it, everything shows as plainError.- Extra fields carry what the handler needs to act (
available), so it doesn't have to parse the message.
A small family of errors
For an app, one base class plus a few specific ones is usually enough:
class AppError extends Error {
constructor(message, options) {
super(message, options);
this.name = new.target.name;
}
}
class ValidationError extends AppError {
constructor(field, message) {
super(message);
this.field = field;
}
}
class NotFoundError extends AppError {}
function handle(error) {
if (error instanceof ValidationError) return `400 ${error.field}: ${error.message}`;
if (error instanceof NotFoundError) return `404 ${error.message}`;
if (error instanceof AppError) return `500 ${error.message}`;
throw error;
}
console.log(handle(new ValidationError("email", "Enter a valid email")));
console.log(handle(new NotFoundError("Order 1042 not found")));
console.log(new NotFoundError("x").name);
try {
handle(new TypeError("x is not a function"));
} catch (error) {
console.log("rethrown:", error.name);
}
400 email: Enter a valid email
404 Order 1042 not found
NotFoundError
rethrown: TypeError
new.target.name sets name to the actual class name, so subclasses don't each need their own this.name = ... line. And notice the last case: a TypeError is a bug, not a business error, so handle throws it again instead of turning it into a polite message.
⚠ Warning: Check the most specific class first. ValidationError is also an AppError, so if the AppError check comes first, every validation error is treated as a generic 500.
Wrapping errors with cause
When you catch a low-level error and throw a clearer one, keep the original as cause:
class AppError extends Error {
constructor(message, options) {
super(message, options);
this.name = new.target.name;
}
}
function loadSettings(text) {
try {
return JSON.parse(text);
} catch (error) {
throw new AppError("Settings file is not valid JSON", { cause: error });
}
}
try {
loadSettings("{theme: dark}");
} catch (error) {
console.log(error.message);
console.log("cause:", error.cause.message);
}
Settings file is not valid JSON
cause: Expected property name or '}' in JSON at position 1 (line 1 column 2)
The new message says what failed in your app's terms. cause keeps the technical reason. Node and the browser console both print the cause chain when the error is logged, so you don't lose anything.
💡 Tip: Errors don't survive JSON.stringify well: it gives {} for a plain Error, because message and stack aren't enumerable. If you send errors to a server or a log service, convert them yourself: { name: e.name, message: e.message, status: e.status }.
When not to make a class
- If nothing catches it specifically, a plain
Errorwith a clear message is fine. - Don't create a class per message (
EmailMissingError,EmailTooLongError). Use oneValidationErrorwith afield. - Don't use errors for normal flow, like "no results found" from a search. An empty array says that better.
Checklist
extends Error(or your base class).super(message, options)first.nameset.- Fields the handler needs.
- Catch with
instanceof, most specific first, and rethrow the rest.
Find one error.message.includes(...) in your code. Which class would replace it?
About
Write one error class, then a small family with a shared base, catch them with instanceof in the right order, and keep the original error with cause. After this, your handlers react to the kind of error, not its wording.
- Responsible
- Christopher R
- Last Update
- 08/31/2026
- Reading Time
- 4 min
Reviews
Sign in to write a reviewComments (0)
Sign in to commentNo comments yet.