- Book: TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a postmortem. The error in the log says
Error: Failed to load user profile: invalid input syntax for type. You read it twice. You know which function threw it because
uuid
its message has the literal string Failed to load user profile,
but the part after the colon is from somewhere four layers below,
and the stack trace goes back as far as the function that did the
wrapping. The original pg error has been flattened into a string.
The file, the line, and the SQL state code that would tell you who
handed in a bad UUID are gone. Whoever wrote that wrapper threw
away the only piece of the puzzle you actually needed.
This is a self-inflicted wound. ES2022 shipped
new Error(message, { cause }) four years ago. V8 had it in 9.3,
which means Node has had it since 16.9.0. Every browser shipped
it within the same year. And yet most TypeScript codebases still
have a lib/errors.ts with a hand-rolled wrap(err, message)
helper, or a class AppError extends Error that stuffs the
original into a field named originalError. Worst of all,
throw new Error(\Failed: ${err.message}) keeps no reference
to the original at all.
Drop the helper. Use cause.
The three patterns to delete
The first one is the silent stack killer:
async function loadUser(id: string): Promise<User> {
try {
return await db.users.findById(id);
} catch (err) {
throw new Error(
`Failed to load user ${id}: ${(err as Error).message}`,
);
}
}
The thrown error has a fresh stack pointing at the throw, the
message of the original concatenated into a string, and zero way
to recover the original instance. If err was a pg.DatabaseError
with a code field of 22P02 (invalid text representation), you
just lost the code, the position, the file, and the type. The
caller cannot do if (err instanceof DatabaseError) because the
DatabaseError is gone.
The second pattern stores the original on a custom field:
class AppError extends Error {
constructor(
message: string,
public readonly originalError?: Error,
) {
super(message);
this.name = "AppError";
}
}
throw new AppError("Failed to load user", err as Error);
This one preserves the original. The cost is that nothing else
knows about originalError. Node's util.inspect does not walk
it. console.log does not unfold it. Observability SDKs have no
convention for a field with that name. Your team walks it manually
in serialiseError(...) and the on-call engineer finds out the
hard way that the new service forgot the helper.
The third pattern is the well-meaning custom helper:
export function wrap(err: unknown, message: string): Error {
const wrapped = new Error(message);
(wrapped as Error & { cause?: unknown }).cause = err;
return wrapped;
}
You are now reimplementing cause by hand, but late, and on the
wrong type. You stripped the type information at the cast. You
attached the cause as enumerable, which cause is not in the
spec, so anything that walks Object.keys(err) will treat it
differently from a real cause. Most importantly: you did not need
to write this function.
What cause actually does
The constructor signature is part of the language:
const cause = await something().catch((e) => e);
throw new Error("Failed to load user profile", { cause });
The Error constructor takes an options bag with a single
recognised key, cause. The runtime sets a non-enumerable
cause property on the new Error whose value is whatever you
passed. Non-enumerable means JSON.stringify will not see it,
Object.keys will not list it, but property access (err.cause)
returns it. Tools that know to look will walk the chain
recursively: util.inspect, V8's own error formatting, and
modern observability SDKs.
In TypeScript, the type for cause is unknown. You are
catching something. It could be an Error, a string someone
threw, or a DOMException, and the language refuses to lie
about what came out of the catch block. The shape of error
handling at the receiving end is the same shape
useUnknownInCatchVariables already pushed you toward in
TypeScript 4.4: narrow before you use.
try {
await loadUser(id);
} catch (err: unknown) {
if (err instanceof Error && err.cause instanceof DatabaseError) {
if (err.cause.code === "22P02") {
return reply.status(400).send({ error: "Bad UUID" });
}
}
throw err;
}
The instanceof check on the outer error is what gets you the
Error shape. The second instanceof walks one level into the
chain. The pattern composes. For a deeper wrap, you check
err.cause on the cause.
What the runtime preserves
This is the part that earns the swap. Each Error in the chain
keeps its own stack trace because each one was constructed at
its own throw site. Node's util.inspect (which is what
console.log calls under the hood for objects) walks the chain
and prints every level:
Error: Failed to load user profile
at loadUser (/app/users.ts:14:11)
at processOrder (/app/orders.ts:42:5)
[cause]: error: invalid input syntax for type uuid: "not-a-uuid"
at Parser.parseErrorMessage (/app/node_modules/pg-protocol/...)
at Parser.handlePacket (/app/node_modules/pg-protocol/...)
at Parser.parse (/app/node_modules/pg-protocol/...)
Two stacks, both intact. The outer one tells you which call site
in the application asked for the user. The inner one tells you
which row in the protocol parser actually choked. The string
concatenation version gave you neither. The outer message
included the inner message, but the stack was a single layer.
Note that the pg driver formats its own Error subclass with
a lowercase error: prefix, which is why the inner line above
reads error: while the outer reads Error:. The cause chain
preserves whatever each layer wrote.
V8 builds the stack string at the moment the Error is
constructed. SpiderMonkey and JavaScriptCore do the same. The
chain is not a special case the engine has to support. It is
two error objects, each holding its own stack, with one
referencing the other through a non-enumerable property.
The catch-shape gotcha
Anything can be thrown. JavaScript does not require thrown
values to be Error instances, and the type of the catch
binding is unknown for that reason. If a library throws a
plain object, or a string, or null, the instanceof Error
check on the cause fails and your narrowing logic skips that
branch. You have two honest options.
Option one: trust nothing, and write a small normaliser at the
boundary where third-party code can throw:
function asError(value: unknown): Error {
if (value instanceof Error) return value;
const msg =
typeof value === "string" ? value : JSON.stringify(value);
return new Error(msg);
}
try {
await thirdPartyThing();
} catch (raw) {
throw new Error("third-party thing failed", {
cause: asError(raw),
});
}
The cause is now always an Error, and the chain stays uniform
all the way up. Option two: keep cause as unknown and have
your top-level handler do the instanceof check before it tries
to read .message. Either is fine; what is not fine is casting
err as Error everywhere and pretending the type system is
wrong.
Where the chain still leaks
Two failure modes are worth naming.
The first is serialisation over the wire. JSON.stringify(err)
returns {} for an Error because none of name, message,
stack, or cause are enumerable. If you ship errors across an
HTTP boundary, an IPC channel, or a worker postMessage, you
have to flatten them by hand:
function serialiseError(err: unknown): unknown {
if (!(err instanceof Error)) return err;
return {
name: err.name,
message: err.message,
stack: err.stack,
cause:
err.cause === undefined
? undefined
: serialiseError(err.cause),
};
}
The receiving side does the inverse: reconstruct an Error
with the deserialised cause as another Error. Without this
step, your worker crashes look like Error: {} on the parent
and the chain is gone.
The second is the observability layer. Most observability
vendors support cause chains in their JS SDKs, but the rendering
of the inner stack is uneven. As of 2026 there is an open issue
against the Sentry JavaScript SDK about cause-stack rendering.
The inner cause is captured, but the stack trace shown in the UI
sometimes points at the outer error instead of the inner one
(sentry-javascript#14983).
Check what your tool actually shows in production. The data is
captured; the rendering is the part that varies.
The migration is mechanical
If you have a wrap(err, message) helper, the codemod is one
line:
// before
throw wrap(err, "Failed to load user profile");
// after
throw new Error("Failed to load user profile", { cause: err });
If you have an AppError extends Error with originalError,
move the field to the constructor's options bag:
class AppError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "AppError";
}
}
throw new AppError("Failed to load user", { cause: err });
Now err.cause works on every AppError, every plain Error,
and every error you build going forward. The custom getter for
originalError goes away. The serialiser uses one path. The
on-call engineer reads two stacks instead of one.
cause is the small ES2022 feature your error layer has been
waiting for. Stop writing the wrapper. The runtime has it.
If this was useful
TypeScript error handling has a shape: unknown catches,
narrowing with instanceof, the discriminated-union pattern for
domain errors, and where to draw the line between a thrown
Error and a returned Result type. That is one of the
day-one chapters in TypeScript Essentials. If your team's
error story is still patchwork, that book is the one that puts
it on a shared foundation.
The deeper type-system tricks land in The TypeScript Type
System: branded error subclasses, template-literal codes, and
conditional types that infer the error union from a function
signature. If you are coming from PHP 8+'s Throwable hierarchy,
PHP to TypeScript maps the patterns side by side. From Java's
checked exceptions, Kotlin and Java to TypeScript covers the
same ground. TypeScript in Production is the one that covers
serialising errors over the wire, the observability-tool
integration, and the build-target choices that affect what
cause compiles to.
The five-book set:
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
- The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471
All five books ship in ebook, paperback, and hardcover.