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

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-10-09T13:30:00.000Z

- Updated: 2026-10-14T11:09:52.936Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/typescript-error-handling-preserve-causes-tests

- Tags: typescript, error-handling, vitest, api-testing, logging, test-design

**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](/blog/typescript-runtime-boundary-validation-testing) 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](/blog/test-api-idempotency-retries-timeouts) before it can be true.

The table the tests will pin down:

| Input failure | Class | User copy | Cause kept | Retry |
| --- | --- | --- | --- | --- |
| 422 with field errors | `ValidationError` | Check the highlighted fields | status, request ID, field names | no |
| 401 | `UnauthorizedError` | Session expired, sign in | status, request ID | no |
| `AbortSignal.timeout()` fires | `RequestTimeoutError` | Slow to respond, try again | the `TimeoutError` DOMException | yes |
| Connection refused (`TypeError: fetch failed`) | `UnknownApiError` | Our side, with a reference | the TypeError and its socket error | yes |
| 404, 409, other 4xx | `UnknownApiError` | Our side, with a reference | status, request ID | no |
| Caller aborts | none, rethrown as is | none | not wrapped | no |

## 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](https://automationtester.in/tools/test-failure-triage#p=eyJ2IjoxLCJpbnB1dCI6IlVua25vd25BcGlFcnJvcjogcmVxdWVzdCBkaWQgbm90IGNvbXBsZXRlXG4gICAgYXQgdHJhbnNwb3J0RmFpbHVyZSAoL2FwcC9zcmMvY2xpZW50LnRzOjQzOjEwKVxuICAgIGF0IGdldEludm9pY2UgKC9hcHAvc3JjL2NsaWVudC50czozMDoxMSkge1xuICBjb3JyZWxhdGlvbklkOiAnY29yci03JyxcbiAga2luZDogJ3Vua25vd24nLFxuICByZXRyeWFibGU6IHRydWUsXG4gIFtjYXVzZV06IFtUeXBlRXJyb3I6IGZldGNoIGZhaWxlZF0ge1xuICAgIFtjYXVzZV06IEVycm9yOiBjb25uZWN0IEVDT05OUkVGVVNFRCAxMjcuMC4wLjE6NTk5OThcbiAgICAgICAgYXQgVENQQ29ubmVjdFdyYXAuYWZ0ZXJDb25uZWN0IFthcyBvbmNvbXBsZXRlXSAobm9kZTpuZXQ6MTcwNToxNikge1xuICAgICAgZXJybm86IC02MSxcbiAgICAgIGNvZGU6ICdFQ09OTlJFRlVTRUQnLFxuICAgICAgc3lzY2FsbDogJ2Nvbm5lY3QnLFxuICAgICAgYWRkcmVzczogJzEyNy4wLjAuMScsXG4gICAgICBwb3J0OiA1OTk5OFxuICAgIH1cbiAgfVxufSJ9)

```
{"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: "asha@example.test" } });
    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("asha@example.test");
  });

  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](/tools/json-diff) shows at a glance whether a field that should never be logged has started to appear.

## Primary references

- [MDN Web Docs: Error: cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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](https://github.com/mdn/browser-compat-data/blob/main/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](https://tc39.es/ecma262/#sec-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()](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static). 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()](https://developer.mozilla.org/en-US/docs/Web/API/Window/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)](https://nodejs.org/api/globals.html). 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](https://www.typescriptlang.org/tsconfig/useUnknownInCatchVariables.html). 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](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-4.html). 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](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html). 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](https://www.typescriptlang.org/tsconfig/#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](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html). 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](https://github.com/microsoft/TypeScript/blob/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)](https://vitest.dev/api/expect.html). 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](https://typescript-eslint.io/rules/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](https://zod.dev/basics). 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.

## Continue reading on AutomationTester.in

- [Mock or Integrate? Playwright Network Testing Boundaries](https://automationtester.in/blog/automation-tutorials/playwright-network-mocking-integration-boundaries)
- [Build Deterministic Python API Test Data Factories](https://automationtester.in/blog/automation-tutorials/python-api-test-data-factories-deterministic)
- [Make JUnit Failures Useful in CI](https://automationtester.in/blog/automation-tutorials/junit-failure-reports-useful-ci)
- [Design Playwright Fixtures for Parallel Test Isolation](https://automationtester.in/blog/automation-tutorials/playwright-fixtures-parallel-test-isolation)
- [Debug JUnit Parallel-Execution Flakes](https://automationtester.in/blog/automation-tutorials/debug-junit-parallel-execution-flakes)

Source: [Test TypeScript Error Handling Without Losing Causes](https://automationtester.in/blog/automation-tutorials/typescript-error-handling-preserve-causes-tests) by Shashank Rawlani.
