Errors and Error Handling

Updated 4 interview angles 4 min read source
On this page11
  1. Why throw Error instead of a string/object?
  2. How do you make a custom error class?
  3. What is the cause option (ES2022)?
  4. What’s the instanceof gotcha with subclassed errors?
  5. Error.captureStackTrace?
  6. How does error handling differ sync vs async?
  7. What is AggregateError?
  8. Gotchas
  9. Related
  10. Further reading
  11. Interview angle

Errors and Error Handling

Always throw Error objects (or subclasses) — never strings — so you get a .stack and instanceof works. Build custom error classes by extending Error for typed handling. Use the ES2022 cause option to chain errors without losing the original. Know the transpilation gotcha that breaks instanceof on subclassed errors, and the difference between sync try/catch and async error propagation.

Why throw Error instead of a string/object?

throw "boom" gives the catcher a bare string — no stack trace, no name, and instanceof Error is false. An Error captures a stack at construction and integrates with reporters and unhandledrejection. Throwing non-Errors is a code smell.

How do you make a custom error class?

js
class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    // so it prints as ValidationError, not Error
    this.name = "ValidationError";
    this.field = field;
  }
}

try {
  throw new ValidationError("Email invalid", "email");
} catch (e) {
  if (e instanceof ValidationError) handleField(e.field);
  // rethrow what you don't own
  else throw e;
}

Set this.name explicitly — it doesn’t default to the class name. Add structured fields (field, code, statusCode) for typed handling instead of string-matching e.message.

What is the cause option (ES2022)?

It chains a low-level error to a higher-level one without discarding context:

js
try {
  await db.query(sql);
} catch (err) {
  throw new Error("Failed to load user", { cause: err });  // preserves the original
}

The catcher can inspect e.cause for the root cause; reporters print both. Before cause, people stuffed the original into the message or a custom field — cause standardizes it.

What’s the instanceof gotcha with subclassed errors?

When targeting old environments, transpilers (Babel/TS down-level to ES5) couldn’t properly subclass the built-in Error, so new MyError() instanceof MyError could be false. The fix is Object.setPrototypeOf(this, MyError.prototype) in the constructor — or simply target ES2015+ where extends Error works natively (the common modern case). Worth naming because it explains “why does my custom error catch as a plain Error?”

Error.captureStackTrace?

A V8-only API to control where the stack starts, hiding framework frames so the trace points at the user’s call site:

js
function assert(cond, msg) {
  if (!cond) {
    const err = new Error(msg);
    if (Error.captureStackTrace) Error.captureStackTrace(err, assert); // omit `assert` itself
    throw err;
  }
}

Used by assertion/validation libraries to keep traces clean. Not available in Safari/Firefox — feature-detect.

How does error handling differ sync vs async?

  • Sync: try/catch catches throws in the same call stack.
  • async/await: try/catch works because await turns rejections into throws.
  • Promises without await: a try/catch will not catch a rejection from a promise you didn’t await — it escapes to unhandledrejection (Promises — Advanced).
  • Callbacks/timers: a throw inside a setTimeout callback can’t be caught by the try/catch around the setTimeout call — different tick, different stack.
js
try { setTimeout(() => { throw new Error("x"); }, 0); } catch (e) {} // does NOT catch

What is AggregateError?

The error type Promise.any rejects with when all inputs reject — it bundles them in .errors. You can also throw it yourself to represent multiple failures (e.g., a batch where several items failed).

Gotchas

  • finally can swallow errors — a return or throw inside finally overrides whatever the try/catch was doing. Keep finally to cleanup.
  • Catching and ignoring (catch (e) {}) hides bugs — at minimum log; rethrow what you don’t handle.
  • catch without a binding (catch {}) is valid ES2019 when you don’t need the error — but usually you do.
  • Errors lose their type across postMessage/structured clone — Error is cloneable but custom fields/subclass identity may not survive worker boundaries; serialize intentionally.
  • error.message is for humans — branch on error.code/instanceof, not on message text (messages get reworded, localized).
  • Rejecting with a non-Error in your own promise propagates a value with no stack — construct an Error.

Further reading

Interview angle 4

  • “How should errors be typed in a codebase?” - as Error subclasses with an explicit name and structured fields, so callers branch on the type or a code rather than on the message text. Matching on message strings breaks the first time someone improves the wording.
  • “How do you keep the original error when you wrap one?” - the cause option: throw new AppError("checkout failed", { cause: err }). It chains without discarding what is underneath, and reporters render the full chain, which is the difference between “checkout failed” and knowing which query timed out.
  • “Why did your try/catch not catch it?” - because the throw happened in a different task. A callback passed to setTimeout runs later on a clean stack, and an un-awaited promise rejects into unhandledrejection, not into the catch around the call that created it.
    • Follow-up: “So how do you catch those?” - top-level window.onerror and unhandledrejection handlers wired to the reporter. Every real application needs both, because they are the only place a whole class of failures shows up at all.
  • “Subclassing Error used to break instanceof. Why?” - a transpilation artifact from targeting ES5, where the subclass prototype was lost. Fixed with Object.setPrototypeOf(this, new.target.prototype) in the constructor, or by targeting ES2015 or later, which is the real answer in 2026.