Error handling has a quiet problem. You catch an error deep in a call stack, wrap it in something more descriptive, and rethrow. The caller gets a meaningful message — but the original error, with its stack trace and details, is gone.
async function loadUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
throw new Error(`Failed to load user ${id}`); // original err swallowed
}
}
Your logs now say Failed to load user 42 and nothing else. Was it a network timeout? A 403? A JSON parse error? You don't know unless you explicitly log before rethrowing — which most people remember only after the third unexplained production incident.
ES2022 added the fix: the cause option on Error.
How Error.cause works
Every Error constructor accepts an optional second argument: an options object. Set cause to the original error and it's preserved on the new error:
async function loadUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
throw new Error(`Failed to load user ${id}`, { cause: err });
}
}
Now the new error carries the original as err.cause. When the caller catches it, both levels are available:
try {
await loadUser(42);
} catch (err) {
console.error(err.message); // "Failed to load user 42"
console.error(err.cause.message); // "HTTP 403"
console.error(err.cause); // the original Error with its full stack trace
}
The original error isn't gone — it's attached. One property to read when you need it, invisible when you don't.
Error chains instead of error strings
The old workaround was to concatenate messages:
throw new Error(`Failed to load user ${id}: ${err.message}`);
This preserves the message text — but only the text. The stack trace of the original error disappears. The error type disappears. If the original error had a cause of its own, that disappears too.
With Error.cause, you get a chain, not a flattened string:
// Higher up the stack
try {
await initDashboard();
} catch (err) {
console.error(err.message); // "Dashboard init failed"
console.error(err.cause.message); // "Failed to load user 42"
console.error(err.cause.cause.message); // "HTTP 403"
}
Every layer adds context. None of them destroy what came before.
A real-world pattern: the service layer
The clearest use case is a service layer wrapping raw API calls:
class UserService {
async getUser(id) {
try {
const raw = await this.api.get(`/users/${id}`);
return User.from(raw);
} catch (err) {
throw new Error(`UserService.getUser(${id}) failed`, { cause: err });
}
}
}
The caller gets a domain error (UserService.getUser(42) failed) without losing the infrastructure detail (HTTP 403, ECONNREFUSED, SyntaxError: Unexpected token). A logging layer can walk the .cause chain to emit structured logs at each level. Error monitoring tools that understand cause chains — Sentry does — can render the full tree as a linked sequence rather than a flattened string.
TypeScript support
TypeScript added the ErrorOptions type in 4.6. The cause field is typed as unknown, which is correct — any value can be a cause, not just Error instances:
throw new Error('Operation failed', { cause: err });
// err.cause is typed as unknown — narrow it before using
if (err.cause instanceof Error) {
console.error(err.cause.message); // ✅ safe
}
Custom error classes work the same way — pass options through to super and the base Error constructor populates this.cause automatically:
class ApiError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'ApiError';
}
}
throw new ApiError('Request failed', { cause: originalError });
cause doesn't have to be an Error
cause accepts any value. If what went wrong was a failed validation, a non-Error rejected promise, or a raw HTTP response object, attach it directly:
throw new Error('Invalid configuration', {
cause: { field: 'timeout', received: -1, expected: '>0' },
});
err.cause holds the original object — not a stringified version of it. That's more useful than trying to serialize context into a message string and more structured than console-logging separately before rethrowing.
Browser support
Error options including cause are Baseline 2022: Chrome 93, Firefox 91, Safari 15.4, Node.js 16.9. Every actively maintained browser and runtime ships it. There is nothing to install and nothing to polyfill; the only thing to change is the habit.
The takeaway
Search your codebase for catch (err) { throw new Error( and look at each one. Where the catch clause doesn't forward err, it's swallowing context someone will want the next time that error appears in production.
Pass { cause: err } as the second argument to Error() and the original error stops disappearing. The message is what you put in the error. The cause is what actually went wrong underneath. They belong in the same object.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 📸 Instagram — frontend best practices, daily: @bestpractice___
- 💼 LinkedIn — linkedin.com/in/parsa-jiravand
- ✉️ Email (work & contract inquiries): bestpractice2026@gmail.com
Top comments (1)
One footgun worth a line in the post:
new Error(msg, err)silently does nothing. The spec only readscauseoff an options object, so passing the error positionally leaveserr.causeasundefined, with no throw and no warning (checked on Node 24). It's one pair of braces away from the correct call, and the only symptom is a chain that's quietly empty at exactly the moment you need it.