{"title":"Test TypeScript Error Handling Without Losing Causes","excerpt":"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.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/typescript-error-handling-preserve-causes-tests","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["typescript","error-handling","vitest","api-testing","logging","test-design"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-09T13:30:00.000Z","dateModified":"2026-10-14T11:09:52.936Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/typescript-error-handling-preserve-causes-tests.webp","alt":"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."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Wrap a failure only when the wrapper adds something the caller can act on, and pass the original as <code class=\"language-text\">{ cause: err }</code> when you do. Give each failure a class with a fixed <code class=\"language-text\">kind</code>, a <code class=\"language-text\">retryable</code> 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 <code class=\"language-text\">toThrow(new SomeError(...))</code> checks the cause, because Vitest skips the cause when the expected error has none.</div>\n\n<div class=\"callout callout-warning\">TypeScript 6.0 changed two defaults this article leans on: <code class=\"language-text\">strict</code> is now on by default, which makes catch-clause variables <code class=\"language-text\">unknown</code>, and the default <code class=\"language-text\">target</code> is now the current year's edition (es2025), which brings in the <code class=\"language-text\">ErrorOptions</code> type and switches on standard class-field semantics. A project that pins <code class=\"language-text\">target</code> or <code class=\"language-text\">lib</code> below es2022 loses the <code class=\"language-text\">ErrorOptions</code> type and the class-field behaviour, whatever its <code class=\"language-text\">strict</code> 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.</div>\n\n<p>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 <code class=\"language-text\">catch</code> block. The message is <code class=\"language-text\">fetch failed</code>. 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.</p>\n\n<p>Here is a client that produces exactly that, and compiles under <code class=\"language-text\">strict</code>:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// invoice-client.ts, before\nexport async function getInvoice(id: string, token: string) {\n  try {\n    const res = await fetch(`${BASE}/invoices/${id}`, {\n      headers: { authorization: `Bearer ${token}` },\n      signal: AbortSignal.timeout(5_000),\n    });\n    if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);\n    return await res.json();\n  } catch (e: any) {\n    logger.error(`invoice ${id} failed: ${e.message}`);\n    throw new Error(e.message);\n  }\n}\n\n// invoice-page.ts\ngetInvoice(id, session.token).catch((e) =&gt; {\n  if (e.message.includes(\"401\")) redirectToLogin();\n  else if (e.message.includes(\"timeout\")) scheduleRetry();\n  else toast(\"Something went wrong\");\n});</code></pre>\n\n<p>Point it at a port with nothing listening and this is the whole record of what happened:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">Error: fetch failed\n    at getInvoice (file:///app/invoice-client.mjs:13:11)\n    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)</code></pre>\n\n<h2 id=\"three-habits-that-erase-the-evidence\">Three habits in that client that erase the evidence</h2>\n\n<p><code class=\"language-text\">catch (e: any)</code> opts out of the check that would have forced the question. Since TypeScript 4.4, <code class=\"language-text\">useUnknownInCatchVariables</code> types a catch-clause variable as <code class=\"language-text\">unknown</code>, and the TSConfig reference gives its default as \"true if strict\". The explicit <code class=\"language-text\">: any</code> 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.</p>\n\n<p><code class=\"language-text\">throw new Error(e.message)</code> keeps the message and nothing else. The name is gone, so a <code class=\"language-text\">TimeoutError</code> arrives upstream as a plain <code class=\"language-text\">Error</code>. The stack now starts at the <code class=\"language-text\">throw</code> 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 <code class=\"language-text\">TypeError</code> whose message is only <code class=\"language-text\">fetch failed</code>, with the socket error, including <code class=\"language-text\">code: 'ECONNREFUSED'</code>, hanging off its <code class=\"language-text\">cause</code>. Copying the message copies the two words that carry no diagnosis. If <code class=\"language-text\">e</code> was not an Error at all, <code class=\"language-text\">e.message</code> is <code class=\"language-text\">undefined</code> and the new error's message is empty.</p>\n\n<p>The string matching fails in a quieter way. <code class=\"language-text\">includes(\"401\")</code> is true for <code class=\"language-text\">HTTP 404: invoice INV-40121 not found</code> whenever the server echoes the ID, so a missing invoice logs the user out. <code class=\"language-text\">includes(\"timeout\")</code> depends on how the runtime words its message, which no specification fixes; MDN's own example for <code class=\"language-text\">AbortSignal.timeout()</code> branches on <code class=\"language-text\">err.name === \"TimeoutError\"</code> instead. And interpolating <code class=\"language-text\">res.text()</code> into the message sends whatever the server put in its error body into your logs, and into the toast if anyone ever renders <code class=\"language-text\">e.message</code>.</p>\n\n<h2 id=\"what-the-cause-option-installs\">What the cause option actually installs</h2>\n\n<p>The mechanism is small enough to read in the specification. <code class=\"language-text\">InstallErrorCause(obj, options)</code> 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:</p>\n\n<pre class=\"language-javascript\"><code class=\"language-javascript\">const err = new Error(\"outer\", { cause: undefined });\n\"cause\" in err;                         // true: the key was present, so the property exists\n\"cause\" in new Error(\"outer\", {});      // false\nObject.keys(new Error(\"x\", { cause: 1 })); // []: cause is not enumerable\nJSON.stringify(new Error(\"x\", { cause: 1 })); // \"{}\": no message, no cause</code></pre>\n\n<p>The first concerns presence. Passing <code class=\"language-text\">{ cause: maybeErr }</code> where <code class=\"language-text\">maybeErr</code> is sometimes <code class=\"language-text\">undefined</code> still creates the property, so a test that asserts <code class=\"language-text\">\"cause\" in err</code> proves nothing about whether anything was preserved. Assert on the value.</p>\n\n<p>The second is enumerability. MDN lists <code class=\"language-text\">cause</code> as writable, configurable and not enumerable, and an Error's <code class=\"language-text\">message</code> is not an own enumerable property either. A logger that serialises errors with <code class=\"language-text\">JSON.stringify</code> writes <code class=\"language-text\">{}</code> 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 <code class=\"language-text\">util.inspect</code>, which <code class=\"language-text\">console.log</code> uses, does walk the chain and prints each link under a <code class=\"language-text\">[cause]</code> key, which is why the same failure looks rich in a terminal and empty in the log aggregator.</p>\n\n<p>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 class=\"language-text\">{ code: \"NonInteger\", values: [p, q] }</code> 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.</p>\n\n<h2 id=\"a-subclass-field-that-deletes-the-cause\">A subclass field that deletes the cause it declares</h2>\n\n<p>Custom error classes pick up the cause from <code class=\"language-text\">super(message, options)</code>; MDN notes that the subclass constructor must pass <code class=\"language-text\">options</code> through for the property to be installed. The trap comes when you narrow its type. <code class=\"language-text\">lib.es2022.error.d.ts</code> declares <code class=\"language-text\">cause?: unknown</code> on <code class=\"language-text\">Error</code>, and redeclaring it looks harmless:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">class Clobbered extends Error {\n  override readonly cause?: unknown; // a field declaration: emitted as `cause;`\n  constructor(message: string, options?: ErrorOptions) {\n    super(message, options);\n  }\n}\n\nclass Declared extends Error {\n  declare readonly cause: unknown; // type information only: nothing emitted\n  constructor(message: string, options?: ErrorOptions) {\n    super(message, options);\n  }\n}\n\nconst inner = new Error(\"inner\");\nnew Clobbered(\"x\", { cause: inner }).cause; // undefined\nnew Declared(\"x\", { cause: inner }).cause;  // Error: inner</code></pre>\n\n<p>Both classes compile. The first one's <code class=\"language-text\">cause</code> is <code class=\"language-text\">undefined</code> at runtime, under <code class=\"language-text\">tsc</code> output and under Vitest's transform alike. With <code class=\"language-text\">useDefineForClassFields</code>, which the TSConfig reference says defaults to true \"if target is ES2022 or higher\", a field declaration is initialised after <code class=\"language-text\">super()</code> 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 <code class=\"language-text\">declare</code> modifier, \"to indicate that a property should have no emit\".</p>\n\n<h2 id=\"four-failure-classes-for-one-invoice-client\">Four failure classes for one invoice client</h2>\n\n<p>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:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">export type FailureKind = \"validation\" | \"unauthorized\" | \"timeout\" | \"unknown\";\n\nexport abstract class ApiError extends Error {\n  abstract readonly kind: FailureKind;\n  abstract readonly retryable: boolean;\n  abstract readonly userMessage: string;\n  declare readonly cause: unknown; // a type annotation only: no field is emitted\n  readonly correlationId: string;\n\n  constructor(message: string, correlationId: string, options?: ErrorOptions) {\n    super(message, options);\n    this.name = new.target.name;\n    this.correlationId = correlationId;\n  }\n}\n\nexport class ValidationError extends ApiError {\n  readonly kind = \"validation\";\n  readonly retryable = false;\n  readonly userMessage = \"Some details were not accepted. Check the highlighted fields.\";\n  readonly fields: readonly string[];\n\n  constructor(fields: readonly string[], correlationId: string, options?: ErrorOptions) {\n    super(`request rejected for fields: ${fields.join(\", \") || \"(none listed)\"}`, correlationId, options);\n    this.fields = fields;\n  }\n}\n\nexport class UnauthorizedError extends ApiError {\n  readonly kind = \"unauthorized\";\n  readonly retryable = false;\n  readonly userMessage = \"Your session has expired. Sign in again to continue.\";\n}\n\nexport class RequestTimeoutError extends ApiError {\n  readonly kind = \"timeout\";\n  readonly retryable = true;\n  readonly userMessage = \"The service is slow to respond. Try again in a moment.\";\n}\n\nexport class UnknownApiError extends ApiError {\n  readonly kind = \"unknown\";\n  readonly retryable: boolean;\n\n  constructor(message: string, correlationId: string, retryable: boolean, options?: ErrorOptions) {\n    super(message, correlationId, options);\n    this.retryable = retryable;\n  }\n\n  get userMessage(): string {\n    return `Something went wrong on our side. Reference: ${this.correlationId}`;\n  }\n}\n\nexport function errorName(value: unknown): string | undefined {\n  return typeof value === \"object\" &amp;&amp; value !== null &amp;&amp; \"name\" in value &amp;&amp; typeof value.name === \"string\"\n    ? value.name\n    : undefined;\n}</code></pre>\n\n<p><code class=\"language-text\">new.target.name</code> sets <code class=\"language-text\">name</code> to the concrete subclass, so <code class=\"language-text\">RequestTimeoutError</code> prints as itself in a stack trace without each class repeating the assignment. <code class=\"language-text\">errorName</code> is a structural check rather than <code class=\"language-text\">instanceof DOMException</code>, for reasons the FAQ below gets into. The client decides which class applies:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">import * as z from \"zod\";\nimport {\n  type ApiError,\n  RequestTimeoutError,\n  UnauthorizedError,\n  UnknownApiError,\n  ValidationError,\n  errorName,\n} from \"./errors\";\n\nexport interface InvoiceClientOptions {\n  baseUrl: string;\n  token: string;\n  timeoutMs?: number;\n  fetchImpl?: typeof fetch;\n  correlationId?: () =&gt; string;\n}\n\nexport async function getInvoice(id: string, opts: InvoiceClientOptions): Promise&lt;unknown&gt; {\n  const correlationId = (opts.correlationId ?? (() =&gt; crypto.randomUUID()))();\n  const fetchImpl = opts.fetchImpl ?? fetch;\n\n  let res: Response;\n  try {\n    res = await fetchImpl(`${opts.baseUrl}/invoices/${encodeURIComponent(id)}`, {\n      headers: { authorization: `Bearer ${opts.token}`, \"x-correlation-id\": correlationId },\n      signal: AbortSignal.timeout(opts.timeoutMs ?? 5_000),\n    });\n  } catch (err) {\n    throw transportFailure(err, correlationId);\n  }\n\n  if (res.ok) return res.json(); // then parse it at the boundary\n  throw await responseFailure(res, correlationId);\n}\n\nfunction transportFailure(err: unknown, correlationId: string): unknown {\n  const name = errorName(err);\n  if (name === \"AbortError\") return err; // the caller cancelled: not ours to classify\n  if (name === \"TimeoutError\") {\n    return new RequestTimeoutError(\"no response before the deadline\", correlationId, { cause: err });\n  }\n  return new UnknownApiError(\"request did not complete\", correlationId, true, { cause: err });\n}\n\nconst ProblemBody = z.object({ errors: z.array(z.object({ field: z.string() })) });\n\nasync function responseFailure(res: Response, correlationId: string): Promise&lt;ApiError&gt; {\n  // Metadata only. The body can echo what was sent, so it never becomes the cause.\n  const upstream = { status: res.status, upstreamRequestId: res.headers.get(\"x-request-id\") };\n\n  if (res.status === 401) {\n    return new UnauthorizedError(`upstream answered ${res.status}`, correlationId, { cause: upstream });\n  }\n  if (res.status === 400 || res.status === 422) {\n    const parsed = ProblemBody.safeParse(await res.json().catch(() =&gt; null));\n    const fields = parsed.success ? parsed.data.errors.map((e) =&gt; e.field) : [];\n    return new ValidationError(fields, correlationId, { cause: { ...upstream, fields } });\n  }\n  return new UnknownApiError(`upstream answered ${res.status}`, correlationId, res.status &gt;= 500, {\n    cause: upstream,\n  });\n}</code></pre>\n\n<p>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 <a href=\"/blog/typescript-runtime-boundary-validation-testing\">validating payloads at the runtime boundary</a> covers what to do with the successful body and how to summarise a rejected one safely. An <code class=\"language-text\">AbortError</code> is passed through untouched, because the caller cancelled on purpose and a user-facing \"slow service\" message would be wrong.</p>\n\n<p>The retry flag on <code class=\"language-text\">UnknownApiError</code> 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 <a href=\"/blog/test-api-idempotency-retries-timeouts\">idempotency and retry article</a> before it can be true.</p>\n\n<p>The table the tests will pin down:</p>\n\n<table>\n<thead><tr><th>Input failure</th><th>Class</th><th>User copy</th><th>Cause kept</th><th>Retry</th></tr></thead>\n<tbody>\n<tr><td>422 with field errors</td><td><code class=\"language-text\">ValidationError</code></td><td>Check the highlighted fields</td><td>status, request ID, field names</td><td>no</td></tr>\n<tr><td>401</td><td><code class=\"language-text\">UnauthorizedError</code></td><td>Session expired, sign in</td><td>status, request ID</td><td>no</td></tr>\n<tr><td><code class=\"language-text\">AbortSignal.timeout()</code> fires</td><td><code class=\"language-text\">RequestTimeoutError</code></td><td>Slow to respond, try again</td><td>the <code class=\"language-text\">TimeoutError</code> DOMException</td><td>yes</td></tr>\n<tr><td>Connection refused (<code class=\"language-text\">TypeError: fetch failed</code>)</td><td><code class=\"language-text\">UnknownApiError</code></td><td>Our side, with a reference</td><td>the TypeError and its socket error</td><td>yes</td></tr>\n<tr><td>404, 409, other 4xx</td><td><code class=\"language-text\">UnknownApiError</code></td><td>Our side, with a reference</td><td>status, request ID</td><td>no</td></tr>\n<tr><td>Caller aborts</td><td>none, rethrown as is</td><td>none</td><td>not wrapped</td><td>no</td></tr>\n</tbody>\n</table>\n\n<h2 id=\"log-once-at-the-edge-that-shows-the-message\">Log once, at the edge that shows the message</h2>\n\n<p>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:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">import { ApiError, type FailureKind, errorName } from \"./errors\";\n\nexport interface FailureLogLine {\n  event: \"api_client_failure\";\n  error: string;\n  kind: FailureKind;\n  message: string;\n  correlationId: string;\n  retryable: boolean;\n  causes: string[];\n}\n\n// Names, codes and statuses only. A cause's message can carry a URL, a query\n// string or an echoed field value, so messages below the top level are dropped.\nexport function causeChain(err: Error, limit = 5): string[] {\n  const chain: string[] = [];\n  let current: unknown = err.cause;\n  while (current !== undefined &amp;&amp; chain.length &lt; limit) {\n    if (typeof current !== \"object\" || current === null) {\n      chain.push(typeof current);\n      break;\n    }\n    const parts = [errorName(current) ?? \"data\"];\n    if (\"code\" in current &amp;&amp; typeof current.code === \"string\") parts.push(current.code);\n    if (\"status\" in current &amp;&amp; typeof current.status === \"number\") parts.push(String(current.status));\n    chain.push(parts.join(\":\"));\n    current = \"cause\" in current ? current.cause : undefined;\n  }\n  return chain;\n}\n\nexport function toLogLine(err: ApiError): FailureLogLine {\n  return {\n    event: \"api_client_failure\",\n    error: err.name,\n    kind: err.kind,\n    message: err.message,\n    correlationId: err.correlationId,\n    retryable: err.retryable,\n    causes: causeChain(err),\n  };\n}\n\nexport function present(err: unknown, log: (line: FailureLogLine) =&gt; void) {\n  if (err instanceof ApiError) {\n    log(toLogLine(err));\n    return { text: err.userMessage, canRetry: err.retryable };\n  }\n  throw err; // not a client failure: let it reach the framework's handler\n}</code></pre>\n\n<p><code class=\"language-text\">causeChain</code> walks the chain by hand because neither of the built-in renderings is safe to ship. <code class=\"language-text\">JSON.stringify</code> drops the chain, and <code class=\"language-text\">util.inspect</code> 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 <code class=\"language-text\">err.request</code>. Here is the same refused connection through the new client, first as <code class=\"language-text\">console.log</code> renders it, then as the log line:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">UnknownApiError: request did not complete\n    at transportFailure (/app/src/client.ts:43:10)\n    at getInvoice (/app/src/client.ts:30:11) {\n  correlationId: 'corr-7',\n  kind: 'unknown',\n  retryable: true,\n  [cause]: [TypeError: fetch failed] {\n    [cause]: Error: connect ECONNREFUSED 127.0.0.1:59998\n        at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1705:16) {\n      errno: -61,\n      code: 'ECONNREFUSED',\n      syscall: 'connect',\n      address: '127.0.0.1',\n      port: 59998\n    }\n  }\n}</code></pre>\n<a href=\"https://automationtester.in/tools/test-failure-triage#p=eyJ2IjoxLCJpbnB1dCI6IlVua25vd25BcGlFcnJvcjogcmVxdWVzdCBkaWQgbm90IGNvbXBsZXRlXG4gICAgYXQgdHJhbnNwb3J0RmFpbHVyZSAoL2FwcC9zcmMvY2xpZW50LnRzOjQzOjEwKVxuICAgIGF0IGdldEludm9pY2UgKC9hcHAvc3JjL2NsaWVudC50czozMDoxMSkge1xuICBjb3JyZWxhdGlvbklkOiAnY29yci03JyxcbiAga2luZDogJ3Vua25vd24nLFxuICByZXRyeWFibGU6IHRydWUsXG4gIFtjYXVzZV06IFtUeXBlRXJyb3I6IGZldGNoIGZhaWxlZF0ge1xuICAgIFtjYXVzZV06IEVycm9yOiBjb25uZWN0IEVDT05OUkVGVVNFRCAxMjcuMC4wLjE6NTk5OThcbiAgICAgICAgYXQgVENQQ29ubmVjdFdyYXAuYWZ0ZXJDb25uZWN0IFthcyBvbmNvbXBsZXRlXSAobm9kZTpuZXQ6MTcwNToxNikge1xuICAgICAgZXJybm86IC02MSxcbiAgICAgIGNvZGU6ICdFQ09OTlJFRlVTRUQnLFxuICAgICAgc3lzY2FsbDogJ2Nvbm5lY3QnLFxuICAgICAgYWRkcmVzczogJzEyNy4wLjAuMScsXG4gICAgICBwb3J0OiA1OTk5OFxuICAgIH1cbiAgfVxufSJ9\">Try this in Test Failure Triage</a>\n\n<pre class=\"language-json\"><code class=\"language-json\">{\"event\":\"api_client_failure\",\"error\":\"UnknownApiError\",\"kind\":\"unknown\",\"message\":\"request did not complete\",\"correlationId\":\"corr-7\",\"retryable\":true,\"causes\":[\"TypeError\",\"Error:ECONNREFUSED\"]}</code></pre>\n\n<p>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 <code class=\"language-text\">ECONNREFUSED</code> without naming the host.</p>\n\n<h2 id=\"tests-that-pin-each-classification\">Tests that pin each classification on its own</h2>\n\n<p>Each test drives one row of the table through the real <code class=\"language-text\">getInvoice</code>, with a stubbed <code class=\"language-text\">fetchImpl</code>, and asserts the four properties the table promises rather than a message:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">import { describe, expect, test, vi } from \"vitest\";\nimport { getInvoice } from \"./client\";\nimport { RequestTimeoutError, UnauthorizedError, UnknownApiError, ValidationError } from \"./errors\";\nimport { present, toLogLine } from \"./log\";\n\nconst TOKEN = \"tok_live_8f2c1d9a\";\nconst base = { baseUrl: \"https://billing.example.test\", token: TOKEN, correlationId: () =&gt; \"corr-7\" };\n\nconst answer = (status: number, body: unknown = {}, headers: Record&lt;string, string&gt; = {}) =&gt;\n  vi.fn&lt;typeof fetch&gt;(async () =&gt; Response.json(body, { status, headers }));\n\nasync function failureOf(work: Promise&lt;unknown&gt;): Promise&lt;unknown&gt; {\n  try {\n    await work;\n  } catch (err) {\n    return err;\n  }\n  throw new Error(\"expected the call to reject\");\n}\n\ndescribe(\"getInvoice failure classification\", () =&gt; {\n  test(\"422 becomes a non-retryable ValidationError naming the fields\", async () =&gt; {\n    const fetchImpl = answer(422, { errors: [{ field: \"currency\" }], echoed: { email: \"asha@example.test\" } });\n    const err = await failureOf(getInvoice(\"INV-40121\", { ...base, fetchImpl }));\n\n    expect.assert(err instanceof ValidationError);\n    expect(err).toMatchObject({ kind: \"validation\", retryable: false, fields: [\"currency\"], correlationId: \"corr-7\" });\n    expect(err.cause).toEqual({ status: 422, upstreamRequestId: null, fields: [\"currency\"] });\n    expect(JSON.stringify(err.cause)).not.toContain(\"asha@example.test\");\n  });\n\n  test(\"401 becomes UnauthorizedError with only upstream metadata as its cause\", async () =&gt; {\n    const fetchImpl = answer(401, { detail: \"token expired\" }, { \"x-request-id\": \"up-991\" });\n    const err = await failureOf(getInvoice(\"INV-1\", { ...base, fetchImpl }));\n\n    expect.assert(err instanceof UnauthorizedError);\n    expect(err.retryable).toBe(false);\n    expect(err.userMessage).toBe(\"Your session has expired. Sign in again to continue.\");\n    expect(err.cause).toEqual({ status: 401, upstreamRequestId: \"up-991\" });\n  });\n\n  test(\"a deadline becomes a retryable RequestTimeoutError that keeps the DOMException\", async () =&gt; {\n    const hang = vi.fn&lt;typeof fetch&gt;(\n      (_url, init) =&gt;\n        new Promise((_resolve, reject) =&gt; {\n          init?.signal?.addEventListener(\"abort\", () =&gt; reject(init.signal?.reason));\n        }),\n    );\n    const err = await failureOf(getInvoice(\"INV-1\", { ...base, fetchImpl: hang, timeoutMs: 20 }));\n\n    expect.assert(err instanceof RequestTimeoutError);\n    expect(err.retryable).toBe(true);\n    expect(err.cause).toBeInstanceOf(DOMException);\n    expect(err.cause).toMatchObject({ name: \"TimeoutError\" });\n  });\n\n  test(\"a transport TypeError becomes UnknownApiError and is kept by reference\", async () =&gt; {\n    const refused = new TypeError(\"fetch failed\", { cause: { code: \"ECONNREFUSED\" } });\n    const fetchImpl = vi.fn&lt;typeof fetch&gt;(async () =&gt; {\n      throw refused;\n    });\n    const err = await failureOf(getInvoice(\"INV-1\", { ...base, fetchImpl }));\n\n    expect.assert(err instanceof UnknownApiError);\n    expect(err.retryable).toBe(true);\n    expect(err.cause).toBe(refused);\n    expect(err.userMessage).toContain(\"corr-7\");\n  });\n\n  test(\"a 404 for an id containing 401 is not an auth failure\", async () =&gt; {\n    const err = await failureOf(getInvoice(\"INV-40121\", { ...base, fetchImpl: answer(404) }));\n\n    expect.assert(err instanceof UnknownApiError);\n    expect(err.retryable).toBe(false);\n    expect(err.cause).toEqual({ status: 404, upstreamRequestId: null });\n  });\n});\n\ndescribe(\"the logging boundary\", () =&gt; {\n  test(\"one line per failure, with the chain and without the token\", async () =&gt; {\n    const lines: unknown[] = [];\n    const err = await failureOf(getInvoice(\"INV-1\", { ...base, fetchImpl: answer(401, { token: TOKEN }) }));\n\n    const shown = present(err, (line) =&gt; lines.push(line));\n\n    expect(shown).toEqual({ text: \"Your session has expired. Sign in again to continue.\", canRetry: false });\n    expect(lines).toEqual([\n      expect.objectContaining({ error: \"UnauthorizedError\", correlationId: \"corr-7\", causes: [\"data:401\"] }),\n    ]);\n    expect(JSON.stringify(lines)).not.toContain(TOKEN);\n  });\n\n  test(\"the chain reaches the socket error under fetch's TypeError\", () =&gt; {\n    const socket = Object.assign(new Error(\"connect ECONNREFUSED 10.0.0.4:443\"), { code: \"ECONNREFUSED\" });\n    const err = new UnknownApiError(\"request did not complete\", \"corr-8\", true, {\n      cause: new TypeError(\"fetch failed\", { cause: socket }),\n    });\n\n    expect(toLogLine(err).causes).toEqual([\"TypeError\", \"Error:ECONNREFUSED\"]);\n    expect(JSON.stringify(toLogLine(err))).not.toContain(\"10.0.0.4\");\n  });\n\n  test(\"anything that is not a client failure is rethrown untouched\", () =&gt; {\n    const bug = new RangeError(\"index out of bounds\");\n    expect(() =&gt; present(bug, () =&gt; {})).toThrow(bug);\n  });\n});</code></pre>\n\n<p>A few choices in that file are deliberate.</p>\n\n<p><code class=\"language-text\">failureOf</code> uses <code class=\"language-text\">try/catch</code> rather than <code class=\"language-text\">.catch((e) =&gt; e)</code>. <code class=\"language-text\">useUnknownInCatchVariables</code> covers catch clauses and nothing else. typescript-eslint's documentation for <code class=\"language-text\">use-unknown-in-catch-callback-variable</code> says that a Promise's <code class=\"language-text\">catch()</code> callback parameter \"will always have the type any\", and the compiler agrees:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">try {\n  await getInvoice(id, opts);\n} catch (err) {\n  err.message; // error TS18046: 'err' is of type 'unknown'.\n}\n\ngetInvoice(id, opts).catch((err) =&gt; err.status.code); // compiles: err is any</code></pre>\n\n<p>The lint rule is in <code class=\"language-text\">strict-type-checked</code>, not the recommended set, and needs type information. Without it, the callback form is where <code class=\"language-text\">any</code> gets back into error handling.</p>\n\n<p><code class=\"language-text\">expect.assert(err instanceof ValidationError)</code> fails the test and narrows the type in one line, so the assertions after it read typed properties without a cast.</p>\n\n<p>The timeout test does not fake the signal. It hands the client a fetch that never settles and lets <code class=\"language-text\">AbortSignal.timeout(20)</code> 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 <code class=\"language-text\">toBeInstanceOf(DOMException)</code> together with the name pins both halves.</p>\n\n<p>The transport test uses <code class=\"language-text\">toBe</code> on the cause, not <code class=\"language-text\">toEqual</code>. The requirement is that the original object survives, and reference identity is the only assertion that cannot pass on a lookalike.</p>\n\n<p>The 404 test uses an ID containing <code class=\"language-text\">401</code>, so it fails if anyone reintroduces message matching.</p>\n\n<p>The token assertion runs on the serialised log line, because that is the artefact that leaves the process.</p>\n\n<h2 id=\"why-tothrow-cannot-see-a-lost-cause\">Why toThrow with an error instance cannot see a lost cause</h2>\n\n<p>The shortest-looking test for this client is <code class=\"language-text\">expect(fn).toThrow(new UnauthorizedError(...))</code>. Vitest's <code class=\"language-text\">toThrow</code> compares any non-string, non-RegExp argument \"using deep equality (similar to toEqual)\", and the <code class=\"language-text\">toEqual</code> documentation carries a warning: for Errors, <code class=\"language-text\">name</code>, <code class=\"language-text\">message</code> and <code class=\"language-text\">cause</code> 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:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">const lost = () =&gt; {\n  throw new UnauthorizedError(\"upstream answered 401\", \"corr-7\"); // cause dropped\n};\n\n// Passes. The expected error has no cause, so the actual one's cause is not compared.\nexpect(lost).toThrow(new UnauthorizedError(\"upstream answered 401\", \"corr-7\"));\n\n// Fails, as it should: the cause is part of what is being asserted.\nexpect(lost).toThrow(expect.objectContaining({ cause: expect.objectContaining({ status: 401 }) }));</code></pre>\n\n<p>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 <code class=\"language-text\">objectContaining</code> for structured data or <code class=\"language-text\">toBe</code> for an original error.</p>\n\n<h2 id=\"where-classification-costs-you\">Where classifying failures costs more than it returns</h2>\n\n<p>Every class has to be kept accurate. A provider that starts answering 403 for expired sessions lands in <code class=\"language-text\">UnknownApiError</code> with retry off, and the only symptom is tickets quoting a reference. A growing <code class=\"language-text\">unknown</code> share in the log lines is the signal that a new class is due.</p>\n\n<p>This is also the place where a language model is useful and where it must not have the last word. Handed a week of <code class=\"language-text\">unknown</code> 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.</p>\n\n<p>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.</p>\n\n<h2 id=\"error-cause-questions\">Questions about error causes in TypeScript</h2>\n\n<h3 id=\"error-iserror-or-instanceof\">Should the timeout check use Error.isError instead of a name check?</h3>\n\n<p>Not yet, for browser code. <code class=\"language-text\">Error.isError()</code> is in the current ECMAScript draft and works across realms where <code class=\"language-text\">instanceof Error</code> 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 <code class=\"language-text\">lib.esnext.error</code>, so you would also need <code class=\"language-text\">esnext</code> in <code class=\"language-text\">lib</code>. A structural check on <code class=\"language-text\">name</code> works in every runtime today.</p>\n\n<h3 id=\"aggregateerror-or-cause\">When is AggregateError the right wrapper instead of cause?</h3>\n\n<p>When there were several independent failures. MDN draws the line clearly: <code class=\"language-text\">cause</code> \"represents a single failing site\", with the wrapper only adding context, while <code class=\"language-text\">AggregateError</code> 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 <code class=\"language-text\">UnauthorizedError</code> is one failure with context, so it is a cause.</p>\n\n<h3 id=\"user-copy-on-the-error-class\">Should the user-facing copy live on the error class?</h3>\n\n<p>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 <code class=\"language-text\">kind</code> 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.</p>\n\n<h2 id=\"classify-one-clients-failures-today\">Classify one client's failures today</h2>\n\n<p>Find the three habits from the start of this article in your codebase:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\"># Catch variables that opt out of unknown, and rethrows that drop the original\ngrep -rnE --include='*.ts' --include='*.tsx' \\\n  -e 'catch \\((\\w+): any\\)' \\\n  -e 'new \\w*Error\\(\\w+\\.message\\)' \\\n  -e '\\.message\\.(includes|startsWith|match)\\(' src</code></pre>\n\n<p>Pick the API client whose failures reach users most often. Replace its catch-all with the four classes, pass every original as <code class=\"language-text\">cause</code>, and put the log call at the one place that renders the message. Grep the same client for <code class=\"language-text\">readonly cause</code> without <code class=\"language-text\">declare</code> in front of it. If your lint already runs with type information, add <code class=\"language-text\">@typescript-eslint/use-unknown-in-catch-callback-variable</code> to catch the promise-callback form the compiler lets through.</p>\n\n<p>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 <a href=\"/tools/json-diff\">JSON Diff tool</a> shows at a glance whether a field that should never be logged has started to appear.</p>\n\n<h2 id=\"error-cause-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause\">MDN Web Docs: Error: cause</a>. That <code class=\"language-text\">cause</code> 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 <code class=\"language-text\">cause</code> \"represents a single failing site\" while <code class=\"language-text\">AggregateError</code> and <code class=\"language-text\">SuppressedError</code> represent multiple; and that the feature has been Baseline across browsers since September 2021.</li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error\">MDN Web Docs: Error</a>. That a custom error class must pass <code class=\"language-text\">options</code> to <code class=\"language-text\">super()</code> for the base constructor to install <code class=\"language-text\">cause</code>, 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.</li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/isError\">MDN Web Docs: Error.isError()</a>. That <code class=\"language-text\">Error.isError()</code> is a branded check that accepts errors from other realms where <code class=\"language-text\">instanceof Error</code> does not, that it returns true for DOMException instances, and that it is \"Limited availability\", not Baseline.</li>\n<li><a href=\"https://github.com/mdn/browser-compat-data/blob/main/javascript/builtins/Error.json\">MDN browser-compat-data: javascript/builtins/Error.json</a>. The compatibility entries for <code class=\"language-text\">Error.isError</code>: 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\".</li>\n<li><a href=\"https://tc39.es/ecma262/#sec-installerrorcause\">ECMAScript 2027 Language Specification (draft): InstallErrorCause</a>. The abstract operation that installs <code class=\"language-text\">cause</code> only when <code class=\"language-text\">HasProperty(options, \"cause\")</code> is true, as a non-enumerable data property, which is why <code class=\"language-text\">{ cause: undefined }</code> still creates the property; and the steps of <code class=\"language-text\">Error.isError</code>, which test for the <code class=\"language-text\">[[ErrorData]]</code> internal slot.</li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static\">MDN Web Docs: AbortSignal.timeout()</a>. That the signal \"aborts with a TimeoutError DOMException\", that its <code class=\"language-text\">reason</code> is set to that exception, and the documented pattern of branching on <code class=\"language-text\">err.name</code> (<code class=\"language-text\">TimeoutError</code> versus <code class=\"language-text\">AbortError</code>) rather than on message text.</li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch\">MDN Web Docs: fetch()</a>. That fetch rejects with an <code class=\"language-text\">AbortError</code> DOMException when aborted and with a <code class=\"language-text\">TypeError</code> for network errors and invalid requests, the two transport shapes the client classifies.</li>\n<li><a href=\"https://nodejs.org/api/globals.html\">Node.js documentation: Globals (fetch, AbortSignal)</a>. That Node's <code class=\"language-text\">fetch()</code> \"is based upon undici\" and that <code class=\"language-text\">AbortSignal.timeout(delay)</code> returns a signal aborted after the delay. The <code class=\"language-text\">TypeError: fetch failed</code> shape with the socket error, including <code class=\"language-text\">code</code>, under <code class=\"language-text\">cause</code>, and <code class=\"language-text\">util.inspect</code> rendering each link as <code class=\"language-text\">[cause]</code>, were observed by running Node.js 26.0.0.</li>\n<li><a href=\"https://www.typescriptlang.org/tsconfig/useUnknownInCatchVariables.html\">TypeScript TSConfig Reference: useUnknownInCatchVariables</a>. That the flag types catch-clause variables as <code class=\"language-text\">unknown</code>, that its default is \"true if strict; false otherwise\", and that it was released in TypeScript 4.4.</li>\n<li><a href=\"https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-4.html\">TypeScript 4.4 release notes</a>. The introduction of <code class=\"language-text\">useUnknownInCatchVariables</code> under <code class=\"language-text\">strict</code>, and that an explicit <code class=\"language-text\">: any</code> on the catch variable is one of the listed ways to \"get around\" the resulting errors.</li>\n<li><a href=\"https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html\">TypeScript 6.0 release notes</a>. That <code class=\"language-text\">strict</code> is now true by default and that <code class=\"language-text\">target</code> defaults to the most recent supported edition, \"right now, that target is es2025\".</li>\n<li><a href=\"https://www.typescriptlang.org/tsconfig/#useDefineForClassFields\">TypeScript TSConfig Reference: useDefineForClassFields</a>. That the flag defaults to true \"if target is ES2022 or higher, including ESNext\".</li>\n<li><a href=\"https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html\">TypeScript 3.7 release notes: the useDefineForClassFields flag and the declare property modifier</a>. 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 <code class=\"language-text\">super()</code>, and that the <code class=\"language-text\">declare</code> modifier marks \"a property should have no emit\". The article confirmed the effect on <code class=\"language-text\">cause</code> with TypeScript 7.0.2 and Vitest 5.0.2.</li>\n<li><a href=\"https://github.com/microsoft/TypeScript/blob/v7.0.2/src/lib/es2022.error.d.ts\">TypeScript v7.0.2: src/lib/es2022.error.d.ts</a>. That <code class=\"language-text\">ErrorOptions</code> and <code class=\"language-text\">cause?: unknown</code> on <code class=\"language-text\">Error</code> are declared in the es2022 library, so the two-argument constructor is a type error with an older <code class=\"language-text\">lib</code>; confirmed with TypeScript 7.0.2, where <code class=\"language-text\">--lib es2021</code> reports \"Expected 0-1 arguments, but got 2\", and that <code class=\"language-text\">Error.isError</code> is declared only in <code class=\"language-text\">lib.esnext.error.d.ts</code>.</li>\n<li><a href=\"https://vitest.dev/api/expect.html\">Vitest documentation: expect API (toThrow, toEqual, rejects, assert)</a>. That <code class=\"language-text\">toThrow</code> compares a non-string, non-RegExp argument \"using deep equality (similar to toEqual)\"; that <code class=\"language-text\">toEqual</code> compares <code class=\"language-text\">name</code>, <code class=\"language-text\">message</code> and <code class=\"language-text\">cause</code> on errors but compares <code class=\"language-text\">cause</code> \"asymmetrically\"; that <code class=\"language-text\">rejects</code> unwraps a rejected promise for matchers; and <code class=\"language-text\">expect.assert</code> for narrowing. The asymmetric pass on a dropped cause was confirmed with Vitest 5.0.2.</li>\n<li><a href=\"https://typescript-eslint.io/rules/use-unknown-in-catch-callback-variable/\">typescript-eslint: use-unknown-in-catch-callback-variable</a>. That <code class=\"language-text\">Promise.prototype.catch()</code> \"is not affected by the useUnknownInCatchVariables compiler option\" and its callback parameter \"will always have the type any\", that the rule is enabled by <code class=\"language-text\">strict-type-checked</code>, and that it requires type information.</li>\n<li><a href=\"https://zod.dev/basics\">Zod 4: Basic usage</a>. That <code class=\"language-text\">safeParse</code> 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.</li>\n</ul>\n"}