# How to wrap errors with Error.cause and keep the original stack

> Wrap a failed fetch or query in a domain error with the cause option, and keep the original error, its stack, and its code reachable.

Source: https://voxgig.com/howto/wrap-errors-with-error-cause

- Audience: library-maintainer
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-20

## Short answer

Wrap a caught error by passing it as the `cause` option, `new Error('could not load order 42', { cause: err })`, and the original stays reachable as `err.cause` with its own stack and properties. `util.inspect(err, { depth: 5 })` prints the chain with each error's stack, where a bare `console.log` stops after three levels. A subclass must forward the options object to `super`. In TypeScript, set `target` or `lib` to `es2022`, or the second argument fails to type-check.

---
## You will need

Node 16.9 or later, or a browser with ES2022 support. The option arrived in
[Node 16.9.0](https://nodejs.org/en/blog/release/v16.9.0),[^1] and an older runtime ignores the
second argument without a warning. Verified 2026-10-21 against Node 22.22.0, `verror` 1.10.1, and
TypeScript 7.0.2.

The sample wraps a real refused connection from Node's built-in `fetch`, aimed at a local port that
was closed a moment earlier, so it needs no network. It stays inside one process. Sending a chain
across a process boundary is a separate task.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [`AggregateError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AggregateError) | Several independent failures at once, such as two replicas that both refused | An `errors` array beside the chain, so a walker that follows `cause` alone never reaches the entries | One operation failed, and you want its context at each level |
| [`Error.cause`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) | Wrapping one failure in a domain error, in any runtime with ES2022 | Nothing to install, but finding a cause by name or code is a walker you write and test yourself | Your codebase already reads every error through another library's helpers |
| [Message concatenation into a new `Error`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/Error) | A one-line message for a person, where no code inspects the result | The original stack, name, and `code` are thrown away, leaving callers to match on strings | Any error that code further up catches and inspects |
| [`VError` cause chains](https://github.com/TritonDataCenter/node-verror#readme) | A codebase built on `VError` that reads `VError.info` and `findCauseByName` | Four packages, a message that repeats every cause's text, and a `cause()` method that standard tools read as a function | Code that also meets standard `cause` chains, such as the errors `fetch` throws |

`Error.cause` is the default because the runtime, `util.inspect`, and Node's own `fetch` all use it.
`VError` adds `sprintf` messages and an `info` object, and it costs a dependency whose helpers return
`null` for a standard cause. `AggregateError` answers a different question, several failures side by
side, and a cause walker skips its `errors` array. Concatenation keeps a readable message but
discards everything a program could act on.

## Pass the original in the options object

The cause goes in an object as the second argument to the constructor. A subclass passes that
object to `super` unchanged, which is the line that keeps the chain intact.

```js title="orders.mjs"
export class OrderLookupError extends Error {
  constructor(message, options) {
    super(message, options)
    this.name = 'OrderLookupError'
  }
}

/** Load one order. A failed call or an unreadable body reaches the caller as err.cause. */
export async function loadOrder(baseUrl, id, { fetch = globalThis.fetch } = {}) {
  const segment = encodeURIComponent(String(id))
  if (id == null || segment === '' || segment === '.' || segment === '..') {
    throw new RangeError(`not an order id: ${JSON.stringify(id)}`)
  }
  let res
  try {
    res = await fetch(`${baseUrl}/orders/${segment}`)
    if (res.ok) return await res.json()
    await res.body?.cancel()
  } catch (err) {
    throw new OrderLookupError(`could not load order ${id}`, { cause: err })
  }
  throw new OrderLookupError(`could not load order ${id}: HTTP ${res.status}`)
}
```

The `try` covers the call and the body parse, so a refused socket and a page of HTML both arrive as
`OrderLookupError` with the reason underneath. An HTTP error status has no lower error to keep, so
that last throw carries a message and no cause. Its unread body is cancelled first, because a body
nobody reads can hold its connection open.

The id goes into the path as one escaped segment, so `../admin` cannot reach another endpoint. An
empty id or a dot segment fails with a `RangeError` before any request, since it is the caller's
mistake rather than a failed lookup.

Run the demo, which calls a port that refuses connections and prints the result with
`util.inspect` at a depth of 5:

```bash
node demo.mjs
```

```text output
OrderLookupError: could not load order 42
    at loadOrder (./orders.mjs:27:11)
    at async ./demo.mjs:10:3 {
  [cause]: TypeError: fetch failed
      at async loadOrder (./orders.mjs:23:11)
      at async ./demo.mjs:10:3 {
    [cause]: Error: connect ECONNREFUSED 127.0.0.1:PORT {
      errno: -111,
      code: 'ECONNREFUSED',
      syscall: 'connect',
      address: '127.0.0.1',
      port: PORT
    }
  }
}
```

The chain holds three errors, each with its own stack. The middle one is Node's: `fetch` reports
a network failure as `TypeError: fetch failed`,[^2] with the reason one level down. The demo
prints through a small `stable` helper in `harness.mjs`, which makes paths relative, drops frames
inside Node's own modules, and masks the port, so the output reproduces on any machine.

## Walk the chain to find the code you branch on

`err.code` on the wrapper is undefined, because the code belongs to the error two levels down. A
walker that follows `cause` reaches it.

```js title="causes.mjs"
export function causes(err, limit = 20) {
  const chain = []
  const seen = new Set()
  for (let e = err; e != null && !seen.has(e) && chain.length < limit; e = e.cause) {
    seen.add(e)
    chain.push(e)
  }
  return chain
}

/** The first link in the chain that passes the test, or undefined. */
export const findCause = (err, test) => causes(err).find(test)
```

The seen set guards against a cause that points back up its own chain, which would send a plain
`while` loop round forever. `isNetworkFailure` in `orders.mjs` asks the walker for one of five codes,
at whatever depth it sits. Three are
[common system errors](https://nodejs.org/docs/latest-v22.x/api/errors.html#common-system-errors)
in Node's list. The other two are [undici's codes](https://undici.nodejs.org/api/Errors), which
`fetch` reports underneath when a server drops the connection or none opens before the connect timeout.

```js title="orders.mjs"
const NETWORK_CODES = new Set([
  'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT',
  'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT',
])

/** True when a socket-level failure sits anywhere under err, however deep. */
export const isNetworkFailure = (err) => Boolean(findCause(err, (e) => NETWORK_CODES.has(e?.code)))
```

The demo prints the chain by name and asks the same question of the refused connection:

```bash
node demo.mjs
```

```text output
message: could not load order 42
chain:   OrderLookupError > TypeError > Error
network failure underneath: true
```

The message keeps the domain's wording, and the retry decision reads `ECONNREFUSED` from two levels
down.

## Measure what each wrapper keeps

The comparison wraps the same refused connection four ways. `AggregateError` comes from
`Promise.any` over two replicas, a built-in that rejects with one when every promise fails.

```bash
node compare.mjs
```

```text output
wrapped with           err.message                            cause chain                           ECONNREFUSED
message concatenation  could not load order 42: fetch failed  Error                                 not reachable
Error.cause            could not load order 42                OrderLookupError > TypeError > Error  2 levels down
VError                 could not load order 42: fetch failed  VError > function                     not reachable
AggregateError         All promises were rejected             AggregateError                        inside err.errors (2)

VError's own helpers on the same error
  typeof err.cause                          function
  VError.cause(err) is the original         true
  VError.cause(original)                    null
  VError.fullStack(err) names ECONNREFUSED  false
```

Concatenation leaves a chain of one, so the code is gone for good. `VError` stores its cause under
`jse_cause` and defines `cause` as a method, so a standard walker reads a function and stops. Its
own helpers fail the other way. The
[`VError` README](https://github.com/TritonDataCenter/node-verror#readme) says the cause of an error
that is not a `VError` is `null`, so `VError.fullStack` prints two stacks and never reaches
`ECONNREFUSED`. `AggregateError` keeps both failures, in `err.errors` rather than in `cause`.

## Check it worked

The test that matters asserts identity rather than a matching message: the wrapper's `cause` is
the same object the call threw.

```js title="orders.test.mjs"
test('the wrapper keeps the original error as its cause', async () => {
  const original = new TypeError('fetch failed')
  const fetch = async () => {
    throw original
  }
  const err = await loadOrder('https://orders.example.com', 42, { fetch }).catch((e) => e)
  assert.ok(err instanceof OrderLookupError)
  assert.equal(err.message, 'could not load order 42')
  assert.equal(err.cause, original)
  assert.equal(Object.keys(err).includes('cause'), false, 'cause is not enumerable')
})
```

```bash
node --test orders.test.mjs
```

```text output
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 242.423273
```

Eleven tests pass. Four of the others follow a failure down the chain: a refused connection, a
dropped one, a connect timeout, and an HTML body wrapped with its `SyntaxError`. Two cover an HTTP
error status, which carries no cause and releases its connection. Two check that an id is escaped as
one path segment or refused, and two hand the walker a chain that loops and a cause that is a plain
string.

## When it goes wrong

The cause is missing, or it sits one level too deep. The error went in as the bare second argument,
or a subclass constructor never passed its options to `super`. The specification installs a cause
only when the second argument
[has a `cause` property](https://tc39.es/ecma262/#sec-installerrorcause), so an error passed bare
installs nothing, or hands over its own cause and skips a level.[^3] Write `{ cause: err }`, and
forward `options` in every subclass.

A log shows `[cause]: [Error]` where the root failure should be. `console.log` formats with the
[`util.inspect` default depth](https://nodejs.org/docs/latest-v22.x/api/util.html#utilinspectobject-options)
of 2, and the fourth error in a chain sits at depth 3. Log `util.inspect(err, { depth: 5 })`, or log
the walker's output.

```bash
node pitfall.mjs
```

```text output
new Error(message, original)
  cause is the original:          false
  cause is the original's cause:  true
a subclass that drops the options
  has a cause:                    false
a four-level chain at the default depth
Error: checkout failed
    at ./pitfall.mjs:29:18 {
  [cause]: OrderLookupError: could not load order 42
      at ./pitfall.mjs:28:16 {
    [cause]: TypeError: fetch failed
        at async ./pitfall.mjs:7:18 {
      [cause]: [Error]
    }
  }
}
```

TypeScript rejects the second argument. `ErrorOptions` and the `cause` property come from the
`es2022.error` library, and a `target` of `es2021` or lower leaves them out:

```bash
npx tsc -p tsconfig.es2021.json
```

```text output
orders.ts(3,42): error TS2304: Cannot find name 'ErrorOptions'.
orders.ts(4,20): error TS2554: Expected 0-1 arguments, but got 2.
orders.ts(11,21): error TS2339: Property 'cause' does not exist on type 'OrderLookupError'.
```

Raise [`target`](https://www.typescriptlang.org/tsconfig/#target) to `es2022`, or add
`es2022.error` to [`lib`](https://www.typescriptlang.org/tsconfig/#lib) if the output keeps an older
syntax target. The runtime still has to support the option either way. The sample's own
`tsconfig.json` takes the first route:

```bash
npx tsc -p tsconfig.json && echo 'es2022: no errors'
```

```text output
es2022: no errors
```

## When not to do this

Do not wrap an error when the wrapper adds nothing. A `catch` that throws again with the same meaning
only lengthens the chain, so let the original propagate.

Do not send a chain to a caller outside your process. A cause carries internal host names, ports, and
stack frames, which an HTTP response should not reveal. Translate the failure at the boundary, and
log the chain on your side.

Do not use `AggregateError` for a sequence in which one failure caused the next. That is a chain, so
wrap each failure in the next with `cause`: an `errors` array holds the failures as peers and cannot
record which caused which.

Do not add `verror` to a codebase that does not already use it. Its helpers are what it offers over
the standard property, and they stop at the first standard cause they meet. A codebase built on it
can bridge the two styles with a walker that calls `cause()` whenever it finds a function there.

## Related how-tos

- [Translate upstream API errors behind your own API](/howto/translate-upstream-errors-behind-your-api)

- [Map vendor error responses onto message errors](/howto/map-vendor-errors-onto-message-errors)

## Last verified

Verified 2026-10-21 against Node 22.22.0, `verror` 1.10.1, and TypeScript 7.0.2. Every output block
is what the command preceding it printed, run in the page's code directory after `npm ci`. The
refused connection is real and local, so no request left the machine. No runtime older than
Node 16.9 was run, and the claim that one ignores the option rests on the release notes.

[^1]: V8 announced error causes on July 7, 2021, in a [feature post](https://v8.dev/features/error-cause)
whose support table says "no support" for Node.js. Node 16.9.0 shipped the option on
September 7, 2021, two months later, and the table has kept its original verdict. The proposal's
[own README](https://github.com/tc39/proposal-error-cause) carries the correction instead: its list
of implementations names Node.js, released on v16.9.0, with a link to the release notes.

[^2]: A refused port, a dropped connection, and an unknown host all produce that one message. The
[Fetch standard](https://fetch.spec.whatwg.org/#fetch-method) asks for nothing more specific: when
the response is a network error, reject the promise with a `TypeError`. Node keeps the reason as
`cause`, which the standard neither requires nor forbids. The TC39 proposal for `cause` opens its
README with the same case, a `fetch` that fails with a bare `TypeError` and a catch block
wondering how to wrap it.

[^3]: The cause travels in an object rather than as a second positional argument, partly because
Firefox was already using that position. Its `Error()` constructor accepts a `fileName` and a
`lineNumber` there, a non-standard extension the
[proposal](https://github.com/tc39/proposal-error-cause) had to step around, by requiring an object
with a `cause` property that can be told apart from a string. An error passed bare is also an
object, which is how the bare-argument mistake gets through.