Skip to main content
Back to Blog
Automation TutorialsTypeScript Quality Engineering ยท Part 6

Test TypeScript Error Handling Without Losing Causes

Rethrowing new Error(e.message) turns a refused connection into two words and a stack that starts in your own catch block. This guide covers what the cause option actually installs, the subclass field that silently resets cause to undefined, an invoice client that classifies validation, unauthorized, timeout and unknown failures with fixed user copy and retry flags, a single redacting log boundary, and Vitest tests that assert each classification, including why toThrow with an error instance cannot see a lost cause.

Shashank Rawlani

Engineering Leader | Builder | Problem Solver | AI Engineer

Published Oct 9, 2026Updated Oct 14, 202622 min read
View AI-ready Markdown
Dark technical illustration read left to right over a faint dashed vertical grid. On the left, three small orange rings linked in a row trail behind a larger ringed orange node, and an orange line carries them into a dashed green rounded box. Inside the box the same three rings reappear in green, linked in a row and enclosed by one large green circle. From the bottom of the box a dashed orange curve carrying a single thin orange pill drops to a crossed-out orange ring. From the right side of the box four green curves fan out to four stacked green panels, each holding a ringed dot and a pill; the lower two panels also hold a small circular green arrow at their right end. All four panels send curves that converge on one ringed green node on the right. Beneath that node a short orange pill runs into a small crossed-out orange ring, while a single green line leaves the node and ends in an arrowhead at the right edge.
Quick answer: Wrap a failure only when the wrapper adds something the caller can act on, and pass the original as { cause: err } when you do. Give each failure a class with a fixed kind, a retryable flag and a user message written in advance, keep the upstream response body out of all three, and write one log line at the edge that shows the message. Then test every classification on its own: the class, the flag, the copy, the retained cause, and the absence of the token in the log line. Do not assert on message text, and do not assume toThrow(new SomeError(...)) checks the cause, because Vitest skips the cause when the expected error has none.
TypeScript 6.0 changed two defaults this article leans on: strict is now on by default, which makes catch-clause variables unknown, and the default target is now the current year's edition (es2025), which brings in the ErrorOptions type and switches on standard class-field semantics. A project that pins target or lib below es2022 loses the ErrorOptions type and the class-field behaviour, whatever its strict setting. Versions checked on 2026-09-30: TypeScript 7.0.2, Vitest 5.0.2, Zod 4.6.5 and typescript-eslint 8.71.0. The current Node.js releases on that date were 26.10.0 and 24.21.0 LTS; every snippet below was compiled under TypeScript 7.0.2 and every test run with Vitest 5.0.2 on Node.js 26.0.0.

A customer reports that the invoice page says "Something went wrong". The log search turns up one line from the invoice client and one stack trace. The trace points at your own catch block. The message is fetch failed. Nothing in the log says whether the service refused the connection, timed out, or rejected the token, so nobody can say whether retrying would have helped or whether the user needs to sign in again.

Here is a client that produces exactly that, and compiles under strict:

// invoice-client.ts, before
export async function getInvoice(id: string, token: string) {
  try {
    const res = await fetch(`${BASE}/invoices/${id}`, {
      headers: { authorization: `Bearer ${token}` },
      signal: AbortSignal.timeout(5_000),
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
    return await res.json();
  } catch (e: any) {
    logger.error(`invoice ${id} failed: ${e.message}`);
    throw new Error(e.message);
  }
}

// invoice-page.ts
getInvoice(id, session.token).catch((e) => {
  if (e.message.includes("401")) redirectToLogin();
  else if (e.message.includes("timeout")) scheduleRetry();
  else toast("Something went wrong");
});

Point it at a port with nothing listening and this is the whole record of what happened:

Error: fetch failed
    at getInvoice (file:///app/invoice-client.mjs:13:11)
    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)

Three habits in that client that erase the evidence

catch (e: any) opts out of the check that would have forced the question. Since TypeScript 4.4, useUnknownInCatchVariables types a catch-clause variable as unknown, and the TSConfig reference gives its default as "true if strict". The explicit : any annotation silently takes that back for this one block, and the release notes list it among the ways to "get around" the new errors, alongside a type assertion or turning the flag off.

throw new Error(e.message) keeps the message and nothing else. The name is gone, so a TimeoutError arrives upstream as a plain Error. The stack now starts at the throw inside the catch, line 13, instead of wherever the failure began. And the cause chain is gone: in Node, fetch reports a refused connection as a TypeError whose message is only fetch failed, with the socket error, including code: 'ECONNREFUSED', hanging off its cause. Copying the message copies the two words that carry no diagnosis. If e was not an Error at all, e.message is undefined and the new error's message is empty.

The string matching fails in a quieter way. includes("401") is true for HTTP 404: invoice INV-40121 not found whenever the server echoes the ID, so a missing invoice logs the user out. includes("timeout") depends on how the runtime words its message, which no specification fixes; MDN's own example for AbortSignal.timeout() branches on err.name === "TimeoutError" instead. And interpolating res.text() into the message sends whatever the server put in its error body into your logs, and into the toast if anyone ever renders e.message.

What the cause option actually installs

The mechanism is small enough to read in the specification. InstallErrorCause(obj, options) checks "If options is an Object and ? HasProperty(options, "cause") is true", then reads the value and defines it on the error as a non-enumerable data property. Two consequences follow from that wording and are easy to confirm in Node:

const err = new Error("outer", { cause: undefined });
"cause" in err;                         // true: the key was present, so the property exists
"cause" in new Error("outer", {});      // false
Object.keys(new Error("x", { cause: 1 })); // []: cause is not enumerable
JSON.stringify(new Error("x", { cause: 1 })); // "{}": no message, no cause

The first concerns presence. Passing { cause: maybeErr } where maybeErr is sometimes undefined still creates the property, so a test that asserts "cause" in err proves nothing about whether anything was preserved. Assert on the value.

The second is enumerability. MDN lists cause as writable, configurable and not enumerable, and an Error's message is not an own enumerable property either. A logger that serialises errors with JSON.stringify writes {} for a bare Error, or only the fields your subclass assigned, and the chain vanishes at exactly the step meant to record it. Node's util.inspect, which console.log uses, does walk the chain and prints each link under a [cause] key, which is why the same failure looks rich in a terminal and empty in the log aggregator.

MDN also makes a point about what a cause may be: "The value of cause can be of any type", and it shows structured data such as { code: "NonInteger", values: [p, q] } used deliberately as a cause. That is the tool for an HTTP failure. There is often no Error to preserve after a 401, only a status and a request ID, and a plain object carrying those is a better cause than the Response, whose body may repeat what you sent.

A subclass field that deletes the cause it declares

Custom error classes pick up the cause from super(message, options); MDN notes that the subclass constructor must pass options through for the property to be installed. The trap comes when you narrow its type. lib.es2022.error.d.ts declares cause?: unknown on Error, and redeclaring it looks harmless:

class Clobbered extends Error {
  override readonly cause?: unknown; // a field declaration: emitted as `cause;`
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
  }
}

class Declared extends Error {
  declare readonly cause: unknown; // type information only: nothing emitted
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
  }
}

const inner = new Error("inner");
new Clobbered("x", { cause: inner }).cause; // undefined
new Declared("x", { cause: inner }).cause;  // Error: inner

Both classes compile. The first one's cause is undefined at runtime, under tsc output and under Vitest's transform alike. With useDefineForClassFields, which the TSConfig reference says defaults to true "if target is ES2022 or higher", a field declaration is initialised after super() returns, and the TypeScript 3.7 release notes spell out that declarations "are always initialized to undefined, even if they have no initializer". The base constructor installs the cause, and the subclass's field definition overwrites it a moment later. Because TypeScript 6.0 moved the default target to es2025, projects that never touched their config now get this behaviour. The fix the same release notes give is the declare modifier, "to indicate that a property should have no emit".

Four failure classes for one invoice client

The replacement splits the client's failures by what the caller should do next, not by HTTP status. Four kinds cover it: the request was invalid, the credentials were rejected, no answer arrived in time, and everything else. Each class fixes its user copy and its retry decision at the point where it is defined, so no caller has to reconstruct them from a message:

export type FailureKind = "validation" | "unauthorized" | "timeout" | "unknown";

export abstract class ApiError extends Error {
  abstract readonly kind: FailureKind;
  abstract readonly retryable: boolean;
  abstract readonly userMessage: string;
  declare readonly cause: unknown; // a type annotation only: no field is emitted
  readonly correlationId: string;

  constructor(message: string, correlationId: string, options?: ErrorOptions) {
    super(message, options);
    this.name = new.target.name;
    this.correlationId = correlationId;
  }
}

export class ValidationError extends ApiError {
  readonly kind = "validation";
  readonly retryable = false;
  readonly userMessage = "Some details were not accepted. Check the highlighted fields.";
  readonly fields: readonly string[];

  constructor(fields: readonly string[], correlationId: string, options?: ErrorOptions) {
    super(`request rejected for fields: ${fields.join(", ") || "(none listed)"}`, correlationId, options);
    this.fields = fields;
  }
}

export class UnauthorizedError extends ApiError {
  readonly kind = "unauthorized";
  readonly retryable = false;
  readonly userMessage = "Your session has expired. Sign in again to continue.";
}

export class RequestTimeoutError extends ApiError {
  readonly kind = "timeout";
  readonly retryable = true;
  readonly userMessage = "The service is slow to respond. Try again in a moment.";
}

export class UnknownApiError extends ApiError {
  readonly kind = "unknown";
  readonly retryable: boolean;

  constructor(message: string, correlationId: string, retryable: boolean, options?: ErrorOptions) {
    super(message, correlationId, options);
    this.retryable = retryable;
  }

  get userMessage(): string {
    return `Something went wrong on our side. Reference: ${this.correlationId}`;
  }
}

export function errorName(value: unknown): string | undefined {
  return typeof value === "object" && value !== null && "name" in value && typeof value.name === "string"
    ? value.name
    : undefined;
}

new.target.name sets name to the concrete subclass, so RequestTimeoutError prints as itself in a stack trace without each class repeating the assignment. errorName is a structural check rather than instanceof DOMException, for reasons the FAQ below gets into. The client decides which class applies:

import * as z from "zod";
import {
  type ApiError,
  RequestTimeoutError,
  UnauthorizedError,
  UnknownApiError,
  ValidationError,
  errorName,
} from "./errors";

export interface InvoiceClientOptions {
  baseUrl: string;
  token: string;
  timeoutMs?: number;
  fetchImpl?: typeof fetch;
  correlationId?: () => string;
}

export async function getInvoice(id: string, opts: InvoiceClientOptions): Promise<unknown> {
  const correlationId = (opts.correlationId ?? (() => crypto.randomUUID()))();
  const fetchImpl = opts.fetchImpl ?? fetch;

  let res: Response;
  try {
    res = await fetchImpl(`${opts.baseUrl}/invoices/${encodeURIComponent(id)}`, {
      headers: { authorization: `Bearer ${opts.token}`, "x-correlation-id": correlationId },
      signal: AbortSignal.timeout(opts.timeoutMs ?? 5_000),
    });
  } catch (err) {
    throw transportFailure(err, correlationId);
  }

  if (res.ok) return res.json(); // then parse it at the boundary
  throw await responseFailure(res, correlationId);
}

function transportFailure(err: unknown, correlationId: string): unknown {
  const name = errorName(err);
  if (name === "AbortError") return err; // the caller cancelled: not ours to classify
  if (name === "TimeoutError") {
    return new RequestTimeoutError("no response before the deadline", correlationId, { cause: err });
  }
  return new UnknownApiError("request did not complete", correlationId, true, { cause: err });
}

const ProblemBody = z.object({ errors: z.array(z.object({ field: z.string() })) });

async function responseFailure(res: Response, correlationId: string): Promise<ApiError> {
  // Metadata only. The body can echo what was sent, so it never becomes the cause.
  const upstream = { status: res.status, upstreamRequestId: res.headers.get("x-request-id") };

  if (res.status === 401) {
    return new UnauthorizedError(`upstream answered ${res.status}`, correlationId, { cause: upstream });
  }
  if (res.status === 400 || res.status === 422) {
    const parsed = ProblemBody.safeParse(await res.json().catch(() => null));
    const fields = parsed.success ? parsed.data.errors.map((e) => e.field) : [];
    return new ValidationError(fields, correlationId, { cause: { ...upstream, fields } });
  }
  return new UnknownApiError(`upstream answered ${res.status}`, correlationId, res.status >= 500, {
    cause: upstream,
  });
}

The 401 branch keeps only the status and the upstream request ID as its cause, which is all an engineer needs to find the matching entry in the provider's logs. The 422 branch reads the body once, through a schema, to extract field names and nothing else; the part-one article on validating payloads at the runtime boundary covers what to do with the successful body and how to summarise a rejected one safely. An AbortError is passed through untouched, because the caller cancelled on purpose and a user-facing "slow service" message would be wrong.

The retry flag on UnknownApiError is true for transport failures and 5xx answers and false for anything else in the 4xx range. That is defensible for a GET. For a request with side effects, a transport failure after the bytes were sent is ambiguous, and the flag needs the reasoning in the idempotency and retry article before it can be true.

The table the tests will pin down:

Input failureClassUser copyCause keptRetry
422 with field errorsValidationErrorCheck the highlighted fieldsstatus, request ID, field namesno
401UnauthorizedErrorSession expired, sign instatus, request IDno
AbortSignal.timeout() firesRequestTimeoutErrorSlow to respond, try againthe TimeoutError DOMExceptionyes
Connection refused (TypeError: fetch failed)UnknownApiErrorOur side, with a referencethe TypeError and its socket erroryes
404, 409, other 4xxUnknownApiErrorOur side, with a referencestatus, request IDno
Caller abortsnone, rethrown as isnonenot wrappedno

Log once, at the edge that shows the message

The old client logged inside its catch and then threw, and the page probably logged again: two lines per failure, neither complete. Move logging to the single place that also decides what the user sees, and have every layer below it throw without logging:

import { ApiError, type FailureKind, errorName } from "./errors";

export interface FailureLogLine {
  event: "api_client_failure";
  error: string;
  kind: FailureKind;
  message: string;
  correlationId: string;
  retryable: boolean;
  causes: string[];
}

// Names, codes and statuses only. A cause's message can carry a URL, a query
// string or an echoed field value, so messages below the top level are dropped.
export function causeChain(err: Error, limit = 5): string[] {
  const chain: string[] = [];
  let current: unknown = err.cause;
  while (current !== undefined && chain.length < limit) {
    if (typeof current !== "object" || current === null) {
      chain.push(typeof current);
      break;
    }
    const parts = [errorName(current) ?? "data"];
    if ("code" in current && typeof current.code === "string") parts.push(current.code);
    if ("status" in current && typeof current.status === "number") parts.push(String(current.status));
    chain.push(parts.join(":"));
    current = "cause" in current ? current.cause : undefined;
  }
  return chain;
}

export function toLogLine(err: ApiError): FailureLogLine {
  return {
    event: "api_client_failure",
    error: err.name,
    kind: err.kind,
    message: err.message,
    correlationId: err.correlationId,
    retryable: err.retryable,
    causes: causeChain(err),
  };
}

export function present(err: unknown, log: (line: FailureLogLine) => void) {
  if (err instanceof ApiError) {
    log(toLogLine(err));
    return { text: err.userMessage, canRetry: err.retryable };
  }
  throw err; // not a client failure: let it reach the framework's handler
}

causeChain walks the chain by hand because neither of the built-in renderings is safe to ship. JSON.stringify drops the chain, and util.inspect prints it along with every own enumerable property of every link, including the peer address on a socket error and anything a well-meaning colleague attaches as err.request. Here is the same refused connection through the new client, first as console.log renders it, then as the log line:

UnknownApiError: request did not complete
    at transportFailure (/app/src/client.ts:43:10)
    at getInvoice (/app/src/client.ts:30:11) {
  correlationId: 'corr-7',
  kind: 'unknown',
  retryable: true,
  [cause]: [TypeError: fetch failed] {
    [cause]: Error: connect ECONNREFUSED 127.0.0.1:59998
        at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1705:16) {
      errno: -61,
      code: 'ECONNREFUSED',
      syscall: 'connect',
      address: '127.0.0.1',
      port: 59998
    }
  }
}
Try this in Test Failure Triage
{"event":"api_client_failure","error":"UnknownApiError","kind":"unknown","message":"request did not complete","correlationId":"corr-7","retryable":true,"causes":["TypeError","Error:ECONNREFUSED"]}

The first is for a terminal. The second is what you can afford to keep for months where many people can query it: the kind, the correlation ID support can quote from the user message, and a chain that still says ECONNREFUSED without naming the host.

Tests that pin each classification on its own

Each test drives one row of the table through the real getInvoice, with a stubbed fetchImpl, and asserts the four properties the table promises rather than a message:

import { describe, expect, test, vi } from "vitest";
import { getInvoice } from "./client";
import { RequestTimeoutError, UnauthorizedError, UnknownApiError, ValidationError } from "./errors";
import { present, toLogLine } from "./log";

const TOKEN = "tok_live_8f2c1d9a";
const base = { baseUrl: "https://billing.example.test", token: TOKEN, correlationId: () => "corr-7" };

const answer = (status: number, body: unknown = {}, headers: Record<string, string> = {}) =>
  vi.fn<typeof fetch>(async () => Response.json(body, { status, headers }));

async function failureOf(work: Promise<unknown>): Promise<unknown> {
  try {
    await work;
  } catch (err) {
    return err;
  }
  throw new Error("expected the call to reject");
}

describe("getInvoice failure classification", () => {
  test("422 becomes a non-retryable ValidationError naming the fields", async () => {
    const fetchImpl = answer(422, { errors: [{ field: "currency" }], echoed: { email: "[email protected]" } });
    const err = await failureOf(getInvoice("INV-40121", { ...base, fetchImpl }));

    expect.assert(err instanceof ValidationError);
    expect(err).toMatchObject({ kind: "validation", retryable: false, fields: ["currency"], correlationId: "corr-7" });
    expect(err.cause).toEqual({ status: 422, upstreamRequestId: null, fields: ["currency"] });
    expect(JSON.stringify(err.cause)).not.toContain("[email protected]");
  });

  test("401 becomes UnauthorizedError with only upstream metadata as its cause", async () => {
    const fetchImpl = answer(401, { detail: "token expired" }, { "x-request-id": "up-991" });
    const err = await failureOf(getInvoice("INV-1", { ...base, fetchImpl }));

    expect.assert(err instanceof UnauthorizedError);
    expect(err.retryable).toBe(false);
    expect(err.userMessage).toBe("Your session has expired. Sign in again to continue.");
    expect(err.cause).toEqual({ status: 401, upstreamRequestId: "up-991" });
  });

  test("a deadline becomes a retryable RequestTimeoutError that keeps the DOMException", async () => {
    const hang = vi.fn<typeof fetch>(
      (_url, init) =>
        new Promise((_resolve, reject) => {
          init?.signal?.addEventListener("abort", () => reject(init.signal?.reason));
        }),
    );
    const err = await failureOf(getInvoice("INV-1", { ...base, fetchImpl: hang, timeoutMs: 20 }));

    expect.assert(err instanceof RequestTimeoutError);
    expect(err.retryable).toBe(true);
    expect(err.cause).toBeInstanceOf(DOMException);
    expect(err.cause).toMatchObject({ name: "TimeoutError" });
  });

  test("a transport TypeError becomes UnknownApiError and is kept by reference", async () => {
    const refused = new TypeError("fetch failed", { cause: { code: "ECONNREFUSED" } });
    const fetchImpl = vi.fn<typeof fetch>(async () => {
      throw refused;
    });
    const err = await failureOf(getInvoice("INV-1", { ...base, fetchImpl }));

    expect.assert(err instanceof UnknownApiError);
    expect(err.retryable).toBe(true);
    expect(err.cause).toBe(refused);
    expect(err.userMessage).toContain("corr-7");
  });

  test("a 404 for an id containing 401 is not an auth failure", async () => {
    const err = await failureOf(getInvoice("INV-40121", { ...base, fetchImpl: answer(404) }));

    expect.assert(err instanceof UnknownApiError);
    expect(err.retryable).toBe(false);
    expect(err.cause).toEqual({ status: 404, upstreamRequestId: null });
  });
});

describe("the logging boundary", () => {
  test("one line per failure, with the chain and without the token", async () => {
    const lines: unknown[] = [];
    const err = await failureOf(getInvoice("INV-1", { ...base, fetchImpl: answer(401, { token: TOKEN }) }));

    const shown = present(err, (line) => lines.push(line));

    expect(shown).toEqual({ text: "Your session has expired. Sign in again to continue.", canRetry: false });
    expect(lines).toEqual([
      expect.objectContaining({ error: "UnauthorizedError", correlationId: "corr-7", causes: ["data:401"] }),
    ]);
    expect(JSON.stringify(lines)).not.toContain(TOKEN);
  });

  test("the chain reaches the socket error under fetch's TypeError", () => {
    const socket = Object.assign(new Error("connect ECONNREFUSED 10.0.0.4:443"), { code: "ECONNREFUSED" });
    const err = new UnknownApiError("request did not complete", "corr-8", true, {
      cause: new TypeError("fetch failed", { cause: socket }),
    });

    expect(toLogLine(err).causes).toEqual(["TypeError", "Error:ECONNREFUSED"]);
    expect(JSON.stringify(toLogLine(err))).not.toContain("10.0.0.4");
  });

  test("anything that is not a client failure is rethrown untouched", () => {
    const bug = new RangeError("index out of bounds");
    expect(() => present(bug, () => {})).toThrow(bug);
  });
});

A few choices in that file are deliberate.

failureOf uses try/catch rather than .catch((e) => e). useUnknownInCatchVariables covers catch clauses and nothing else. typescript-eslint's documentation for use-unknown-in-catch-callback-variable says that a Promise's catch() callback parameter "will always have the type any", and the compiler agrees:

try {
  await getInvoice(id, opts);
} catch (err) {
  err.message; // error TS18046: 'err' is of type 'unknown'.
}

getInvoice(id, opts).catch((err) => err.status.code); // compiles: err is any

The lint rule is in strict-type-checked, not the recommended set, and needs type information. Without it, the callback form is where any gets back into error handling.

expect.assert(err instanceof ValidationError) fails the test and narrows the type in one line, so the assertions after it read typed properties without a cast.

The timeout test does not fake the signal. It hands the client a fetch that never settles and lets AbortSignal.timeout(20) fire for real, so the cause under test is the DOMException the platform creates. MDN documents that the signal's reason is "a TimeoutError DOMException", and asserting toBeInstanceOf(DOMException) together with the name pins both halves.

The transport test uses toBe on the cause, not toEqual. The requirement is that the original object survives, and reference identity is the only assertion that cannot pass on a lookalike.

The 404 test uses an ID containing 401, so it fails if anyone reintroduces message matching.

The token assertion runs on the serialised log line, because that is the artefact that leaves the process.

Why toThrow with an error instance cannot see a lost cause

The shortest-looking test for this client is expect(fn).toThrow(new UnauthorizedError(...)). Vitest's toThrow compares any non-string, non-RegExp argument "using deep equality (similar to toEqual)", and the toEqual documentation carries a warning: for Errors, name, message and cause are compared, but "For Error.cause, the comparison is done asymmetrically". An expected error with no cause matches an actual error with any cause, or none:

const lost = () => {
  throw new UnauthorizedError("upstream answered 401", "corr-7"); // cause dropped
};

// Passes. The expected error has no cause, so the actual one's cause is not compared.
expect(lost).toThrow(new UnauthorizedError("upstream answered 401", "corr-7"));

// Fails, as it should: the cause is part of what is being asserted.
expect(lost).toThrow(expect.objectContaining({ cause: expect.objectContaining({ status: 401 }) }));

Both halves were run against Vitest 5.0.2: the first passes on an error whose cause was dropped, the second fails on it. A test written before you added causes keeps passing after someone removes them again. Assert the cause explicitly, with objectContaining for structured data or toBe for an original error.

Where classifying failures costs more than it returns

Every class has to be kept accurate. A provider that starts answering 403 for expired sessions lands in UnknownApiError with retry off, and the only symptom is tickets quoting a reference. A growing unknown share in the log lines is the signal that a new class is due.

This is also the place where a language model is useful and where it must not have the last word. Handed a week of unknown log lines, which carry names, codes and statuses rather than bodies, a model will cluster them into plausible categories quickly. Whether a cluster is retryable, and what the user is told, is still your decision, and each proposed class gets a row in the table and a test before it ships.

Skip all of this in a CLI or a build script, where the printed stack trace is the interface and its reader is the person who can fix it. And do not wrap an error you cannot classify better than it already is; rethrowing the original is correct when the wrapper would add nothing but a new message.

Questions about error causes in TypeScript

Should the timeout check use Error.isError instead of a name check?

Not yet, for browser code. Error.isError() is in the current ECMAScript draft and works across realms where instanceof Error does not, but MDN marks it "Limited availability", not Baseline. MDN's compatibility data adds the detail that matters here: Safari 18.4 ships it as a partial implementation that "returns false for DOMException instances", which is exactly the class a timeout produces. Node.js has the full behaviour from 24.3.0. TypeScript only declares it in lib.esnext.error, so you would also need esnext in lib. A structural check on name works in every runtime today.

When is AggregateError the right wrapper instead of cause?

When there were several independent failures. MDN draws the line clearly: cause "represents a single failing site", with the wrapper only adding context, while AggregateError represents "a collection of multiple, unrelated errors that occurred during the same operation". Fanning a request out to three mirrors and losing all three is an AggregateError. A 401 wrapped in UnauthorizedError is one failure with context, so it is a cause.

Should the user-facing copy live on the error class?

For a single-language application it is the simplest arrangement that keeps copy and retry policy next to each other and under test. If the interface is translated, keep kind on the error and map it to a translation key at the presentation edge instead; the tests then assert the key, and the classification tests stay the same.

Classify one client's failures today

Find the three habits from the start of this article in your codebase:

# Catch variables that opt out of unknown, and rethrows that drop the original
grep -rnE --include='*.ts' --include='*.tsx' \
  -e 'catch \((\w+): any\)' \
  -e 'new \w*Error\(\w+\.message\)' \
  -e '\.message\.(includes|startsWith|match)\(' src

Pick the API client whose failures reach users most often. Replace its catch-all with the four classes, pass every original as cause, and put the log call at the one place that renders the message. Grep the same client for readonly cause without declare in front of it. If your lint already runs with type information, add @typescript-eslint/use-unknown-in-catch-callback-variable to catch the promise-callback form the compiler lets through.

The evidence to keep is the failure table above filled in for that client, one test per row, and a captured log line for each kind, committed next to the tests. Diffing a captured line before and after a change in the JSON Diff tool shows at a glance whether a field that should never be logged has started to appear.

Primary references

  • MDN Web Docs: Error: cause. That cause is writable, configurable and not enumerable; that "the value of cause can be of any type", with structured data shown as a deliberate cause; that cause "represents a single failing site" while AggregateError and SuppressedError represent multiple; and that the feature has been Baseline across browsers since September 2021.
  • MDN Web Docs: Error. That a custom error class must pass options to super() for the base constructor to install cause, that passing the original as cause means "the original error and stack trace are available to higher-level try/catch blocks", and that libraries should prefer cause over asking consumers to parse messages.
  • MDN Web Docs: Error.isError(). That Error.isError() is a branded check that accepts errors from other realms where instanceof Error does not, that it returns true for DOMException instances, and that it is "Limited availability", not Baseline.
  • MDN browser-compat-data: javascript/builtins/Error.json. The compatibility entries for Error.isError: Chrome 134, Firefox 138, full support in Node.js from 24.3.0, and Safari 18.4 as a partial implementation that "returns false for DOMException instances".
  • ECMAScript 2027 Language Specification (draft): InstallErrorCause. The abstract operation that installs cause only when HasProperty(options, "cause") is true, as a non-enumerable data property, which is why { cause: undefined } still creates the property; and the steps of Error.isError, which test for the [[ErrorData]] internal slot.
  • MDN Web Docs: AbortSignal.timeout(). That the signal "aborts with a TimeoutError DOMException", that its reason is set to that exception, and the documented pattern of branching on err.name (TimeoutError versus AbortError) rather than on message text.
  • MDN Web Docs: fetch(). That fetch rejects with an AbortError DOMException when aborted and with a TypeError for network errors and invalid requests, the two transport shapes the client classifies.
  • Node.js documentation: Globals (fetch, AbortSignal). That Node's fetch() "is based upon undici" and that AbortSignal.timeout(delay) returns a signal aborted after the delay. The TypeError: fetch failed shape with the socket error, including code, under cause, and util.inspect rendering each link as [cause], were observed by running Node.js 26.0.0.
  • TypeScript TSConfig Reference: useUnknownInCatchVariables. That the flag types catch-clause variables as unknown, that its default is "true if strict; false otherwise", and that it was released in TypeScript 4.4.
  • TypeScript 4.4 release notes. The introduction of useUnknownInCatchVariables under strict, and that an explicit : any on the catch variable is one of the listed ways to "get around" the resulting errors.
  • TypeScript 6.0 release notes. That strict is now true by default and that target defaults to the most recent supported edition, "right now, that target is es2025".
  • TypeScript TSConfig Reference: useDefineForClassFields. That the flag defaults to true "if target is ES2022 or higher, including ESNext".
  • TypeScript 3.7 release notes: the useDefineForClassFields flag and the declare property modifier. That under define semantics declarations "are always initialized to undefined, even if they have no initializer", that re-declaring an inherited property without an initializer resets it after super(), and that the declare modifier marks "a property should have no emit". The article confirmed the effect on cause with TypeScript 7.0.2 and Vitest 5.0.2.
  • TypeScript v7.0.2: src/lib/es2022.error.d.ts. That ErrorOptions and cause?: unknown on Error are declared in the es2022 library, so the two-argument constructor is a type error with an older lib; confirmed with TypeScript 7.0.2, where --lib es2021 reports "Expected 0-1 arguments, but got 2", and that Error.isError is declared only in lib.esnext.error.d.ts.
  • Vitest documentation: expect API (toThrow, toEqual, rejects, assert). That toThrow compares a non-string, non-RegExp argument "using deep equality (similar to toEqual)"; that toEqual compares name, message and cause on errors but compares cause "asymmetrically"; that rejects unwraps a rejected promise for matchers; and expect.assert for narrowing. The asymmetric pass on a dropped cause was confirmed with Vitest 5.0.2.
  • typescript-eslint: use-unknown-in-catch-callback-variable. That Promise.prototype.catch() "is not affected by the useUnknownInCatchVariables compiler option" and its callback parameter "will always have the type any", that the rule is enabled by strict-type-checked, and that it requires type information.
  • Zod 4: Basic usage. That safeParse returns a result object that "is a discriminated union" instead of throwing, used to read field names from a 422 body without trusting its shape.

TypeScript Quality Engineering

Get part 7: Make TypeScript Type-Level Tests Earn Their Place

It is live now: read part 7. The form below subscribes you to The Automation Brief, the site newsletter.

No spam, no sending cadence promised. Unsubscribe any time.