Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 027 / 100

Use error.cause: a practical JavaScript & TypeScript guide

Keep the original error when wrapping.

Expona Admin8 min read

Introduction

When exceptions bubble up through layers of your application, you often need to wrap them in domain-specific errors or add context before re-throwing. The challenge is that most developers lose the original error stack trace in the process, making production debugging a nightmare. Use error.cause: a practical JavaScript & TypeScript guide walks through the standard way modern JavaScript handles error chains, preserving the root cause for better observability and faster incident response.

Error chaining has been part of languages like Java for decades, but JavaScript only standardized it in ES2022. Understanding when and how to use error.cause transforms how your team debugs production issues and maintains logging infrastructure.

Supported in modern browsers and Node

The cause property is defined in the ECMAScript specification and has solid support across modern runtimes. Node.js 16.9.0+ supports it natively, and all major browsers released in the last few years include it. If you're targeting older environments, you can polyfill or manually construct the chain, but for most teams building on current stacks, this is a native feature you can rely on today.

Before relying on error.cause in your codebase, check your package.json target and CI/CD environment versions. A quick test in your REPL confirms availability:

typescript

try {
  throw new Error('original error');
} catch (err) {
  const wrapped = new Error('wrapped error', { cause: err });
  console.log(wrapped.cause); // Error: original error
}

The second argument to the Error constructor accepts an options object with a cause property. This is the standard pattern—no custom fields, no string concatenation. Once you adopt it, your team's logging and monitoring tools can parse and display error chains automatically.

Preserves the root cause for debugging

When an error is wrapped without preserving its cause, you lose critical debugging information. Consider a real-world scenario: a database operation fails due to a connection timeout, and your application layer catches it, wraps it in a business logic error, then returns a 500 to the client. Without the original error, you're left guessing whether it was a network issue, authentication failure, or query timeout.

Using error.cause, you capture the full chain:

typescript

async function fetchUserData(userId: string): Promise {
  try {
    const response = await db.query(
      'SELECT * FROM users WHERE id = $1',
      [userId]
    );
    return response.rows[0];
  } catch (error) {
    throw new Error(
      `Failed to fetch user ${userId}`,
      { cause: error }
    );
  }
}

async function getUserProfile(userId: string) {
  try {
    return await fetchUserData(userId);
  } catch (error) {
    throw new Error(
      'Could not load user profile',
      { cause: error }
    );
  }
}

// At the edge, handler logs the error
app.use((err: Error, req: Request, res: Response) => {
  console.error('Error:', err.message);
  console.error('Cause chain:', getCauseChain(err));
  res.status(500).json({ error: err.message });
});

function getCauseChain(error: Error): string[] {
  const chain = [error.message];
  let current = error.cause as Error | undefined;
  while (current) {
    chain.push(current.message);
    current = current.cause as Error | undefined;
  }
  return chain;
}

In this example, if the database connection times out, the end-user sees "Could not load user profile", but your logs contain the full chain: connection timeout → query execution error → fetch user error → profile load error. This distinction is the difference between a 30-minute investigation and a 30-second diagnosis.

Loggers can print the chain

Most production logging systems and error tracking services (Sentry, Datadog, LogRocket) now recognize and display error cause chains automatically. If yours doesn't yet, writing a small utility to walk the chain takes minutes and pays dividends immediately.

Here's a practical logging helper compatible with common Node.js patterns:

typescript

interface FormattedError {
  message: string;
  stack?: string;
  cause?: FormattedError;
}

function formatErrorWithCause(error: Error): FormattedError {
  const formatted: FormattedError = {
    message: error.message,
    stack: error.stack,
  };

  if (error.cause instanceof Error) {
    formatted.cause = formatErrorWithCause(error.cause);
  }

  return formatted;
}

// Usage with Winston, Pino, or console.error
logger.error('User operation failed', {
  error: formatErrorWithCause(err),
  userId,
  timestamp: new Date().toISOString(),
});

// Output in logs:
// {
//   "message": "Could not load user profile",
//   "stack": "Error: Could not load user profile\n    at getUserProfile...",
//   "cause": {
//     "message": "Failed to fetch user abc123",
//     "stack": "Error: Failed to fetch user abc123\n    at fetchUserData...",
//     "cause": {
//       "message": "connect ECONNREFUSED 127.0.0.1:5432",
//       "stack": "Error: connect ECONNREFUSED..."
//     }
//   }
}

When errors are structured this way, your monitoring dashboard can surface the root cause directly without manual log digging. Teams using centralized logging ingest these chains, index them, and make them searchable—speeding up the entire incident response workflow.

Better than string concatenation of messages

Many codebases still concatenate error messages into a single string. While this feels simpler in the moment, it breaks down quickly in production:

typescript

// Anti-pattern: string concatenation
try {
  performDatabaseQuery();
} catch (error) {
  const message = `DB query failed: ${error.message}`;
  throw new Error(message);
}

// Result after multiple wraps:
// "HTTP request failed: Payment processing error: DB query failed: connect ECONNREFUSED"
// Stack trace lost. Message increasingly unreadable.

This approach loses the original stack trace (where the error actually occurred), makes messages harder to parse programmatically, and forces your logging system to work backward from concatenated strings to understand the real failure.

With error.cause, each layer adds context without destroying what came before:

typescript

// Better: use error.cause
try {
  performDatabaseQuery();
} catch (error) {
  throw new Error(
    'DB query failed',
    { cause: error as Error }
  );
}

// Result after multiple wraps:
// Top-level message: "HTTP request failed"
// → cause: "Payment processing error"
//   → cause: "DB query failed"
//     → cause: original stack trace from query library
// Every layer retains its stack trace and context.

Your error tracking system can now parse each layer independently, aggregate by root cause (connect ECONNREFUSED patterns), and correlate incidents across services. Alerts become specific instead of noisy.

Common mistakes

Forgetting to pass Error objects

The cause property should hold an Error object, not a string. Some developers write { cause: err.message }, which discards the stack trace:

typescript

// Wrong
throw new Error('wrapped', { cause: error.message });

// Right
throw new Error('wrapped', { cause: error });

If your original error is already a string (from a catch block that didn't receive an Error), wrap it first:

typescript

let original: unknown;
try {
  // some operation
} catch (err) {
  original = err;
}

// Later, ensure cause is an Error or undefined
const cause = original instanceof Error ? original : new Error(String(original));
throw new Error('context', { cause });

Logging the entire error object without formatting

If you pass an Error with a cause chain to JSON.stringify directly, only the top-level message appears because Error properties don't enumerate by default. Always use a helper like the one shown above or check your logger's built-in support:

typescript

// Problematic
console.log(JSON.stringify(error)); // {"message":"...","name":"Error"}
// cause is lost

// Better
console.error(error); // Node.js native console prints the full stack and cause
// Or use a structured logging library that handles this

Creating circular cause chains

Rare but possible: if error A causes error B which causes error A again, your cause chain formatter will loop infinitely. Add a Set to track seen errors:

typescript

function formatErrorWithCause(
  error: Error,
  seen = new Set()
): FormattedError {
  if (seen.has(error)) {
    return { message: '[Circular error chain]' };
  }
  seen.add(error);

  const formatted: FormattedError = {
    message: error.message,
    stack: error.stack,
  };

  if (error.cause instanceof Error) {
    formatted.cause = formatErrorWithCause(error.cause, seen);
  }

  return formatted;
}

Key takeaways

  • Use error.cause instead of string concatenation to preserve stack traces and original error context across exception layers.
  • Pass Error objects, not messages to the cause option when wrapping exceptions so your logging and monitoring can extract structured information.
  • Build a simple formatter or use logger support to walk the cause chain and output it to your observability system, making root cause analysis faster during incidents.
  • Check Node and browser version support before relying on error.cause, but for most modern codebases, it's available and stable today.
  • Avoid circular cause chains by tracking seen errors when walking the chain in formatters or recursive utilities.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles