Custom Errors
The built-in error subtypes — TypeError, RangeError, SyntaxError and friends — cover language-level failures. For your domain failures (HTTP, validation, business rules) you write custom error classes that extend Error. A custom class gives every catch block a clean instanceof test and a place to attach extra fields like status or field.
The minimal recipe
Extend Error, call super(message), and set name. That is enough in any modern environment.
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
try {
throw new ValidationError("email is required");
} catch (err) {
console.log(err instanceof ValidationError); // true
console.log(err instanceof Error); // true
console.log(err.name, err.message); // "ValidationError" "email is required"
}Carrying extra data
The whole point of a custom error is to ship more than a message. Add fields in the constructor and let catch blocks pull them out.
class HttpError extends Error {
constructor(response, body) {
super(`HTTP ${response.status} ${response.statusText}`);
this.name = "HttpError";
this.status = response.status;
this.url = response.url;
this.body = body;
}
}
try {
await fetchJson("/api/me");
} catch (err) {
if (err instanceof HttpError && err.status === 401) {
redirectToLogin();
return;
}
throw err;
}Wrapping a lower-level error
When you catch one error and throw another, attach the original via cause so the trail is not lost.
class ConfigError extends Error {
constructor(message, options) {
super(message, options); // pass { cause } through
this.name = "ConfigError";
}
}
try {
JSON.parse(rawConfig);
} catch (cause) {
throw new ConfigError("config.json is malformed", { cause });
}Error's constructor already accepts { cause } — passing options straight through is all you need.
Fixing the prototype chain (older targets)
When you transpile class to ES5 (or run in old environments) the prototype chain can be lost — err instanceof CustomError returns false. The fix is a one-liner inside the constructor.
class NotFoundError extends Error {
constructor(message) {
super(message);
this.name = "NotFoundError";
// Restore the prototype chain when downlevelled to ES5.
Object.setPrototypeOf(this, NotFoundError.prototype);
}
}If your build target is ES2015+ you do not need this — modern extends handles it. Add the line only when you ship to environments where instanceof is unreliable.
Hierarchies of errors
A small hierarchy lets callers match at the right granularity — sometimes any HTTP failure, sometimes only 4xx, sometimes only "not found".
class HttpError extends Error {
constructor(message, status) {
super(message);
this.name = "HttpError";
this.status = status;
}
}
class ClientError extends HttpError {} // 4xx
class ServerError extends HttpError {} // 5xx
class NotFoundError extends ClientError {}
try {
await loadPost(id);
} catch (err) {
if (err instanceof NotFoundError) return show404();
if (err instanceof ClientError) return showInputError(err);
if (err instanceof ServerError) return showRetry();
throw err;
}AggregateError — bundling many failures
AggregateError is a built-in custom error designed to wrap a list of others. Promise.any throws it when every input rejects, but you can use it yourself when reporting batch failures.
async function loadAll(ids) {
const results = await Promise.allSettled(ids.map(loadOne));
const failures = results
.filter((r) => r.status === "rejected")
.map((r) => r.reason);
if (failures.length) {
throw new AggregateError(failures, `${failures.length} loads failed`);
}
return results.map((r) => r.value);
}When to define a custom error
You need callers to branch on the failure type. A different
catchpath → a different class.You need to carry structured data alongside the message (status code, field name, retry-after).
You want to identify errors crossing a module boundary — a library should expose its own classes so consumers can match on them.
You are wrapping a lower-level error and want the new layer to be discoverable.
If none of those apply, a plain new Error("...") is fine. Do not write a new class just to set name.
Serialising errors
JSON.stringify(new Error("oops")) returns "{}" because Error's fields are non-enumerable. If you log errors as JSON, define a toJSON or pull fields out explicitly.
class ApiError extends Error {
constructor(message, status) {
super(message);
this.name = "ApiError";
this.status = status;
}
toJSON() {
return { name: this.name, message: this.message, status: this.status };
}
}
console.log(JSON.stringify(new ApiError("bad request", 400)));
// {"name":"ApiError","message":"bad request","status":400}