JavaScript stack traces are a mess — so I fixed mine
I came from Java. Say what you want about the ceremony — when something blows up in production, Java hands you the whole story: every layer of the call stack, each Caused by: frame stacked neatly underneath, right down to the root. What broke, where, and why, in a single trace.
Then I started writing serious Node.js, and production broke, and the stack trace told me almost nothing.
An error three layers deep, and all I had was the outermost wrapper — Error: request failed — with the actual culprit swallowed somewhere in a Promise chain I’d never get to see. So I’d start guessing. Add a log line, redeploy, wait, reproduce, repeat: an afternoon of archaeology to recover one fact the runtime already had in its hands and threw away.
The first time it happened, I assumed I’d done something wrong. By the tenth, I knew it wasn’t me — it was the language. So I started building error-extender.
What JavaScript gets wrong
When you catch an error and throw a new one — which you should, to add context — the original error disappears unless you explicitly pass it along. And even if you do pass it along with Error.cause, it doesn’t actually help much in practice:
const err = new Error('query failed', { cause: rootError });
console.error(err.stack);
// Error: query failed
// at Object.<anonymous> (/app/service.js:12:9)
// ...
// (rootError is just... gone from the output)
Here’s the part that actually stings: even when you do thread the cause through, most loggers and aggregators serialise error.stack and nothing else. Some can be coaxed into printing cause — opt-in, per-tool, inconsistent — but anything that just reads .stack still sees nothing. Neither does plain console.error(err.stack). Neither do you, grepping logs at 2am. That root cause you carefully threaded through is sitting right there in memory, and your pipeline drops it on the floor. It’s already ES2026 and this is still the situation.
In Java, you’d get:
ServiceException: query failed
at com.example.Service.query(Service.java:45)
...
Caused by: java.sql.SQLException: connection refused
at com.example.db.Pool.connect(Pool.java:112)
...
I wanted that. In Node.js.
What error-extender does
It bakes the cause chain directly into the stack string — not into a separate property, but into the stack trace itself. That means every logger, every log aggregator, every console.error that reads .stack gets the full chain, no custom serialiser needed:
DatabaseError: query failed
at Object.<anonymous> (/app/service.js:12:9)
...
Caused by: Error: connection refused
at Object.<anonymous> (/app/db.js:5:19)
...
It also handles two other things I got tired of repeating:
Typed structured context. Attaching a status, requestId, or affectedResource to an error shouldn’t require boilerplate on every custom class. error-extender gives every error class a typed data field out of the box.
Inheritable defaults. Define defaultMessage and defaultData at a parent error class; child classes inherit and deep-merge them. DatabaseError can inherit { status: 500 } from ServiceError and only override what it needs.
import { extendError } from 'error-extender';
const ServiceError = extendError('ServiceError');
const DatabaseError = extendError('DatabaseError', { parent: ServiceError });
try {
throw new Error('connection refused');
} catch (root) {
throw new DatabaseError({ message: 'Query failed.', cause: root });
}
// Stack output:
// DatabaseError: Query failed.
// at Object.<anonymous> (/app/db.js:10:9)
// ...
// Caused by: Error: connection refused
// at Object.<anonymous> (/app/db.js:7:9)
// ...
Why I’m reviving it now
I built this in 2018, used it, and moved on. The package sat on npm doing mostly nothing.
Recently I reached for it again in a new codebase — same frustration, same problem — and realised it still had something native JavaScript doesn’t. The core idea held up. What hadn’t aged as well was the codebase itself: no TypeScript, old tooling, thin README.
Rewriting it properly would normally mean carving out weekends I don’t have. My day job is intense — leading teams at Samsung R&D isn’t the kind of work that leaves a lot of energy for side projects. But with Claude, the modernisation (TypeScript rewrite, updated build pipeline, proper documentation with before/after examples) came together in a few focused sessions. I directed, reviewed, and made the judgment calls; Claude handled the scaffolding.
The result: error-extender is TypeScript-first now, same API, and a README that actually explains the why.
If you’ve ever lost a root cause in production and thought “I miss Java exceptions” — it’s on npm.