DEV Community

Cover image for You're rethrowing errors and losing context. `Error.cause` fixes that.
Parsa Jiravand
Parsa Jiravand

Posted on • Edited on • Originally published at bestpractic.org

You're rethrowing errors and losing context. `Error.cause` fixes that.

Watch out for the positional argument footgun

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

The original error isn't gone โ€” it's attached. One property to read when you need it, invisible when you don't.

๐ŸŽฎ Try it yourself

โ–ถ๏ธ Open the interactive playground โ†’

Runs right in your browser โ€” poke at it and watch the concept react live.

Error chains instead of error strings

The old workaround was to concatenate messages:

throw new Error(`Failed to load user ${id}: ${err.message}`);
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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 });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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' },
});
Enter fullscreen mode Exit fullscreen mode

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.

๐Ÿง  Test yourself

Think it clicked? Take the 6-question quiz โ†’

Instant feedback, a hint on every question, and an explanation for each answer โ€” right or wrong.

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:

Top comments (6)

Collapse
 
svyatov profile image
Leonid Svyatov

One footgun worth a line in the post: new Error(msg, err) silently does nothing. The spec only reads cause off an options object, so passing the error positionally leaves err.cause as undefined, 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.

Collapse
 
parsajiravand profile image
Parsa Jiravand

That's a really good catch. The API is deceptively easy to misuse because new Error(message, err) looks plausible, but the second argument is only interpreted as an options object, so the correct form is:

new Error(message, { cause: err });
Enter fullscreen mode Exit fullscreen mode

If you pass the error positionally, it fails silentlyโ€”the error is still created, but cause is never set, which makes debugging much harder when you eventually need to inspect the error chain.

Thanks for pointing this out. It's exactly the kind of subtle gotcha that's worth calling out explicitly because there's no warning to tell you you've done anything wrong.

Collapse
 
raju_dandigam profile image
Raju Dandigam

Error.cause is one of those features that quietly upgrades operational debugging when teams actually use it consistently. The production win is not just preserving the stack, it is keeping the causal chain machine-readable so logs, traces, and retry logic can separate wrapper context from the underlying failure.

We have found it especially valuable at service boundaries where a generic "request failed" can hide transport, auth, schema, and parse failures that need completely different responses.

Curious whether you also serialize cause chains into structured logs, or mostly use them at catch boundaries in code?

Collapse
 
parsajiravand profile image
Parsa Jiravand

I completely agree. I think the biggest benefit of Error.cause isn't just for developers reading stack tracesโ€”it's that the relationship between errors becomes explicit and machine-readable.

At service boundaries, that context is especially valuable because a high-level "request failed" error can wrap very different root causes, and treating them all the same often leads to poor retry or recovery decisions.

In my own projects, I mainly use cause at catch boundaries to add context as errors move up the stack. If the project has structured logging or observability in place, I also like preserving the entire cause chain in the logs so it's possible to trace back to the original failure without losing the intermediate context.

I think that's where Error.cause really shinesโ€”it encourages wrapping errors with meaningful domain context instead of replacing them and throwing away the information that actually explains what happened.

Collapse
 
nazar-boyko profile image
Nazar Boyko

One thing worth a warning if people start walking the chain in a logger: nothing stops a cause from pointing back at an error already in the chain, so a naive while (err.cause) loop can spin forever. Cheap fix is a visited set or a depth cap. Rare, but it's the kind of bug that only shows up once the error you're logging is the reason logging fell over.

Collapse
 
publiflow profile image
PubliFlow

Error.cause is a game changer for debugging complex async flows. Before this, we had to manually append stack traces or use custom error classes just to keep the original context intact when bubbling up errors from database queries to the API layer. I actually implemented this pattern extensively when building out the server actions for our Next.js SaaS boilerplate, PubliFlow, because tracing Supabase RLS failures without the original context was a nightmare. Have you found any edge cases where the native cause property gets stripped out by certain logging libraries or bundlers?