JavaScriptCustom Error Classes

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.

JS
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"
}
Why set name?
Some loggers and devtools display `err.name + ": " + err.message`. Without setting `name` you would see `Error: ...` even for a `ValidationError`. It is a one-line fix with a big readability payoff.
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.

JS
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.

JS
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.

JS
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".

JS
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;
}
Do not go overboard
Three to five domain errors per service is usually enough. A class per failure mode becomes maintenance overhead — group similar failures behind one class with a discriminator field instead.
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.

JS
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 catch path → 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.

JS
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}
One file, many errors
Keep your domain errors in a single `errors.ts` (or per-feature) module. Co-locating them makes the catch sites read as documentation: every `instanceof X` points back to a defined class with its own constructor and fields.