Every error Node throws is an Error or a subclass, carrying three properties worth knowing. name is the class name, message the human sentence, and stack a string — the message plus one at ... line per frame, captured when the object was constructed, not when it was thrown. Cache one error instance and rethrow it and the trace points at the wrong place.
Catching an error and throwing a new one loses the original. The cause option, in the language since ES2022, keeps it:
import { readFile } from 'node:fs/promises';
async function loadConfig(path) {
try {
return JSON.parse(await readFile(path, 'utf8'));
} catch (err) {
throw new Error(`Cannot load config from ${path}`, { cause: err });
}
}
try { await loadConfig('./settings.json'); } catch (err) {
console.log(err.name, '|', err.message);
console.log('cause:', err.cause.code, '|', err.cause.message);
}Error | Cannot load config from ./settings.json cause: ENOENT | ENOENT: no such file or directory, open '/srv/shop/settings.json'
The caller gets a message in its own vocabulary while the operational detail — a missing file rather than malformed JSON — survives one property away. Causes nest: a database layer wraps a driver error, a service wraps that, and console.error walks the chain, printing each link under an indented [cause]: label. Node's own fetch does this, which is why a DNS failure surfaces as a bland TypeError: fetch failed whose cause carries the real ENOTFOUND (Unhandled Rejections shows one).
Two other shapes appear in real code. AggregateError holds an array in .errors and is what Promise.any throws when every input rejects; Promise.allSettled never rejects, so a batch import reports every failure at once rather than stopping at the first.