Ch. 1 · JavaScript

JavaScript Custom Error Classes

How to write a JavaScript Error subclass that keeps its name, stack and cause, when instanceof fails, and what to catch versus rethrow.

~6 min readintermediateupdated Oct 19, 2026

Most JavaScript codebases start by throwing new Error('something went wrong') and checking err.message.includes('timeout') in the catch block. It works until someone rewords a message and a retry loop silently stops retrying. Custom error classes fix that by turning “what kind of failure was this?” into a type you can check, plus structured fields (a status code, a URL, the original cause) that callers can read without parsing strings.

The class itself is five lines. What interviewers probe is whether you know the details that make it behave: the name property, the cause option, why instanceof sometimes lies, and what happens when an error crosses a network boundary.

A subclass that behaves like a real error

Here’s an HttpError for a fetch wrapper:

class HttpError extends Error {
  constructor(status, url, options) {
    super(`HTTP ${status} for ${url}`, options);
    this.name = 'HttpError';
    this.status = status;
    this.url = url;
  }
}

const e = new HttpError(503, '/api/orders');
console.log(e instanceof HttpError, e instanceof Error, e.name, e.message);
// true true HttpError HTTP 503 for /api/orders
console.log(String(e));
// HttpError: HTTP 503 for /api/orders
JavaScript

Each line in the constructor is doing a specific job.

super(message, options) has to come first. In a derived class, this doesn’t exist until the parent constructor runs, so touching this.status before super throws a ReferenceError. Passing options through matters too, because that’s how cause reaches the base Error (more on that below).

this.name = 'HttpError' is the line people forget. Error.prototype.name is "Error", and a subclass inherits it. Without the assignment you get this:

class Plain extends Error {}
const p = new Plain('boom');
console.log(p.name, String(p));  // Error Error: boom
JavaScript

The class works for instanceof, but logs, error trackers and stack traces all label it Error, which makes production triage slower. If you have many subclasses, set the name once in a base class with this.name = new.target.name; new.target is the class that was actually constructed, so a NotFound that extends your base gets name === 'NotFound' for free. Be aware that minifiers can rename classes, so if your names show up in logs from a minified bundle, hard-coded strings are safer.

The extra fields are the real payoff. A caller can now write if (err instanceof HttpError && err.status === 503) and retry, without depending on the wording of a message.

What about Error.captureStackTrace and setPrototypeOf?

Older tutorials include Error.captureStackTrace(this, HttpError) or Object.setPrototypeOf(this, HttpError.prototype). With native class syntax in any current engine, you need neither. The stack is captured by the base constructor, and the prototype chain is set up by extends.

setPrototypeOf was a workaround for TypeScript or Babel compiling classes down to ES5 functions. Extending built-ins like Error doesn’t work properly with ES5 function constructors, and instanceof returned false for the subclass. If you target ES2015 or later (every maintained browser), the workaround isn’t needed. If an interviewer asks why it exists, that history is the answer.

Wrapping failures with cause

A low-level error rarely makes sense to the layer above. A SyntaxError from JSON.parse tells the order page nothing useful; “the orders API returned something that isn’t JSON” does. But if you throw a new error and drop the original, you’ve lost the stack trace that points at the real problem.

ES2022 added a cause option to every error constructor for exactly this:

async function loadOrders() {
  try {
    JSON.parse('<html>');
  } catch (err) {
    throw new HttpError(502, '/api/orders', { cause: err });
  }
}

try {
  await loadOrders();
} catch (err) {
  console.log(err.message, '|', err.cause.name + ': ' + err.cause.message);
}
// HTTP 502 for /api/orders | SyntaxError: Unexpected token '<', "<html>" is not valid JSON
JavaScript

The outer error describes the failure in the caller’s terms, and err.cause holds the original with its own stack. Node’s console.log prints the whole chain, showing the cause under a [cause]: key, and Sentry and most other error trackers display it as a linked exception. That <html> example is realistic: it’s what you get when a proxy or load balancer returns an error page instead of the API’s JSON.

cause is set as a non-enumerable own property, the same as message and stack. It can hold any value, not just an error, though passing the original error object is the convention.

When instanceof stops working

instanceof checks whether HttpError.prototype is on the object’s prototype chain. That’s reliable inside one global environment with one copy of your code, and fragile outside it.

Three situations break it:

  1. Duplicate packages. If your app ends up with two versions of a library in node_modules, each has its own HttpError class. An error thrown by version 1.4 isn’t an instanceof the class imported from version 2.0, even though both are called HttpError.
  2. Other globals. An error created in an iframe or a vm context in Node has a different Error constructor, so it isn’t even instanceof Error in the parent.
  3. Serialisation. An error that was sent over postMessage, a network call or a job queue comes back as a plain object or a base Error. Its class is gone.

The common defence is a stable string field. Node’s own errors do this with code: a failed file read has err.code === 'ENOENT', and you’re encouraged to check that rather than the message. Give your errors a code (or a status for HTTP errors) and check it in places where the error might have crossed one of those boundaries. Use instanceof where you control both sides.

Errors across an API boundary

Try serialising the error from earlier:

console.log(JSON.stringify(e));
// {"name":"HttpError","status":503,"url":"/api/orders"}
JavaScript

The message and stack are missing. JSON.stringify only includes enumerable own properties, and message, stack and cause are all non-enumerable. name, status and url appear only because the constructor assigned them as ordinary properties.

This is usually a good accident. You don’t want stack traces, file paths and internal messages going to clients. But it means you should convert errors to responses on purpose, at one place in your server (an Express error middleware, for example): pick the status, a public message and a code, log the full error with its cause chain internally, and send only the safe fields out.

Catch narrowly, rethrow the rest

The other half of good error classes is how you catch them. A common mistake looks like this:

try {
  await saveOrder(order);
} catch (err) {
  showToast('Could not save order');
}
JavaScript

That catch swallows everything, including a TypeError from a typo inside saveOrder. The user sees a friendly toast, and the bug never shows up in your error tracker. Check for the failures you can handle and rethrow anything else:

try {
  await saveOrder(order);
} catch (err) {
  if (err instanceof HttpError && err.status === 409) {
    showToast('Someone else changed this order. Reload to see their edit.');
    return;
  }
  throw err;
}
JavaScript

Now a conflict gets specific handling and a programming error still crashes loudly where you’ll see it.

Two more points interviewers sometimes raise. You can throw anything in JavaScript (throw 'oops' is legal), so code that catches errors from third-party libraries should not assume err.message exists. And don’t build a deep hierarchy: a base AppError with a handful of subclasses that callers actually branch on is plenty. If no caller ever distinguishes InvalidEmailError from InvalidPhoneError, one ValidationError with a field property does the same job.

Interview exercise: design errors for a payments client

Question. You’re writing a client for a payments API. Callers need to retry network blips, show a specific message when a card is declined, and surface everything else as a bug. Sketch the error classes and the caller’s catch block.

Answer. Start with a base class that sets the name and carries a stable code, then add only the subclasses a caller will branch on:

class PaymentError extends Error {
  constructor(message, code, options) {
    super(message, options);
    this.name = new.target.name;
    this.code = code;
  }
}
class CardDeclinedError extends PaymentError {
  constructor(reason, options) {
    super(`Card declined: ${reason}`, 'CARD_DECLINED', options);
    this.reason = reason;
  }
}
class TransientPaymentError extends PaymentError {
  constructor(message, options) {
    super(message, 'TRANSIENT', options);
  }
}
JavaScript

Inside the client, wrap low-level failures with cause so the original fetch or parse error stays attached: a TypeError: fetch failed becomes new TransientPaymentError('Network error contacting payments', { cause: err }). The caller retries TRANSIENT a bounded number of times with backoff, shows the decline reason for CARD_DECLINED, and rethrows anything else. Checking err.code rather than instanceof keeps this working if the client ships as a separate package and ends up duplicated in node_modules. Two things a strong answer adds: never retry a declined card (the code makes that impossible to get wrong by accident), and make sure the payment request uses an idempotency key so a retry after a timeout can’t charge the customer twice.

More in JavaScript

esc