How-to › Maintain libraries across languages

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.

Audience
Library maintainer
Level
beginner
Topic
Choose and build small utilities
Languages
TypeScript and JavaScript
Verified

Your data layer catches a failed fetch and throws new Error('could not load order 42: ' + err.message) in its place. The log line shows one message and one stack, both from the wrapper. The socket error’s stack and its code are gone, so the retry logic that branched on ECONNREFUSED falls through to its default case.

What you get

You will end up with a domain error that keeps the original as err.cause, a walker that finds any error in the chain, and a test that proves the original survives. This is for you if your library wraps errors from fetch or a database driver.

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,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

ApproachWhen it fitsWhat it costs youWhen to pick something else
AggregateErrorSeveral independent failures at once, such as two replicas that both refusedAn errors array beside the chain, so a walker that follows cause alone never reaches the entriesOne operation failed, and you want its context at each level
Error.causeWrapping one failure in a domain error, in any runtime with ES2022Nothing to install, but finding a cause by name or code is a walker you write and test yourselfYour codebase already reads every error through another library’s helpers
Message concatenation into a new ErrorA one-line message for a person, where no code inspects the resultThe original stack, name, and code are thrown away, leaving callers to match on stringsAny error that code further up catches and inspects
VError cause chainsA codebase built on VError that reads VError.info and findCauseByNameFour packages, a message that repeats every cause’s text, and a cause() method that standard tools read as a functionCode 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.

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:

node demo.mjs
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.

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 in Node’s list. The other two are undici’s codes, which fetch reports underneath when a server drops the connection or none opens before the connect timeout.

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:

node demo.mjs
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.

node compare.mjs
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 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.

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')
})
node --test orders.test.mjs
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, 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 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.

node pitfall.mjs
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:

npx tsc -p tsconfig.es2021.json
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 to es2022, or add es2022.error to 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:

npx tsc -p tsconfig.json && echo 'es2022: no errors'
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.

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.

Footnotes

  1. V8 announced error causes on July 7, 2021, in a feature post 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 carries the correction instead: its list of implementations names Node.js, released on v16.9.0, with a link to the release notes. ↩︎ Back to text

  2. A refused port, a dropped connection, and an unknown host all produce that one message. The Fetch standard 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. ↩︎ Back to text

  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 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.