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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
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 | 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 | 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 | 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.
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.
Related how-tos
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
-
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
-
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 ascause, which the standard neither requires nor forbids. The TC39 proposal forcauseopens its README with the same case, afetchthat fails with a bareTypeErrorand a catch block wondering how to wrap it. ↩︎ Back to text -
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 afileNameand alineNumberthere, a non-standard extension the proposal had to step around, by requiring an object with acauseproperty 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