Why unknown-error?
The Problem
In TypeScript's strict mode, a catch variable is unknown, and with reason: JavaScript can throw anything. To log it, report it or wrap it, you need an Error, so nearly every codebase ends up with something like this:
const toError = (value: unknown): Error =>
value instanceof Error ? value : new Error(String(value));It handles the cases that come to mind first: an Error, or a thrown string. It falls apart on the rest, and it fails inside the catch block that was meant to handle the failure.
Errors from another realm aren't instanceof Error
Each realm (a vm context, an iframe, some test runners) has its own Error class. An Error from another realm fails instanceof Error, so the code above turns a perfectly good TypeError into new Error("TypeError: x"), losing its name, stack, cause and properties.
Errors that crossed a boundary are plain objects
An error sent through JSON, postMessage or an RPC framework arrives as { name, message, stack, code }. String() turns it into Error: [object Object].
Thrown objects become [object Object]
throw { status: 404, reason: 'missing' } is bad practice, but libraries do it. String() loses everything, and JSON.stringify throws on circular references and BigInts, which is why most conversions don't use it.
The handler itself can throw
The thrown value controls what runs when you inspect it. A Proxy can throw from every trap, a message getter can throw, and so can toString and toJSON. String(value), value.message and even value instanceof Error (which reads the prototype) run that code, so the conversion throws inside your catch and replaces the original error with a new one.
Cause chains and AggregateError are trees
cause can be anything, including a non-Error or another realm's Error, and it can be circular. AggregateError adds a list of errors at each level. Code that follows cause with a while loop hangs on a cycle, and code that recurses overflows the call stack on a deep enough chain.
Why Existing Solutions Fall Short
Every claim below is checked by the comparison suite, which runs each library through the same cases.
normalize-exceptioncomes closest. It handles other realms, error-like objects and hostile values, and normalizes causes andAggregateErrorentries. But it modifies the error you pass in (replacing a non-Errorcausein place), describes circular objects and objects containing BigInts as[object Object], and recurses, so a 10,000-deep cause chain overflows the stack.ensure-errorusesinstanceof, so it wraps another realm's Error in aNonErrorwhose message is{}. It doesn't recognize error-like objects, throws on a Proxy or a throwingmessagegetter, and also patches your error in place. It recurses too, and builds a new stack at every level, which gets slow on deep chains whenError.stackTraceLimitis raised (as Mocha does).serialize-erroris built to turn errors into JSON and back. ItsdeserializeErrorrebuilds error-like objects well, but prefixes thrown strings (Non-error value: boom), throws on hostile values, and doesn't walk causes.pony-causeonly formatscausechains of real Errors: it drops causes that aren't Errors (including other realms' Errors), ignoresAggregateError, and recurses.- Hand-written conversions like the one above fail most of these cases, and throw on three of them.
How unknown-error Is Different
- Every read is guarded. Property reads, key listing and stringifying all catch errors from getters and Proxy traps, with fallbacks down to a fixed
[unprintable value]. No function in the package throws, whatever it's given. Error.isErrorwhere it exists. It recognizes Errors from any realm, and can't be fooled by a Proxy or run any of its traps. Where it's missing (Chrome before 134, Firefox before 138, Safari before 18.4), a guardedinstanceofand object-tag check takes over.- Your errors are never modified. An Error is returned as it is. Error-like objects are copied into a new Error, and everything else is wrapped in a
NonErrorthat keeps the original value asthrown. - Readable descriptions. Non-Errors are described as JSON with circular references marked
[Circular]and BigInts kept as1n, falling back totoStringand then the object's tag. - A walk, not recursion.
walkErrorsuses an explicit stack, skips anything it has already visited, and stops atmaxDepth(32 by default), marking where it stopped.formatErroris built on it.
Comparison
From the comparison suite. ✅ handles it, ❌ doesn't, 💥 throws, — has no API for it.
| Case | unknown-error | normalize-exception | ensure-error | serialize-error | pony-cause | Hand-written |
|---|---|---|---|---|---|---|
| Uses a thrown string as the message, unchanged | ✅ | ✅ | ❌ quotes it | ❌ prefixes it | — | ✅ |
Converts a thrown null | ✅ | ✅ | ✅ | ✅ | — | ✅ |
| Keeps an Error from another realm | ✅ | ✅ | ❌ | ✅ | — | ❌ |
| Rebuilds an error-like object with its name, message, code and stack | ✅ | ✅ | ❌ | ✅ | — | ❌ |
| Describes a circular object | ✅ | ❌ | ❌ | ❌ | — | ❌ |
| Describes an object containing a BigInt | ✅ | ❌ | ❌ | ❌ | — | ❌ |
Converts an object whose toJSON and toString throw | ✅ | ✅ | ✅ | ✅ | — | 💥 |
| Converts a Proxy that throws from every trap | ✅ | ✅ | 💥 | 💥 | — | 💥 |
Reads the message of an Error whose message getter throws | ✅ | ✅ | 💥 | 💥 | — | 💥 |
| Leaves the Error passed in unmodified | ✅ | ❌ | ❌ | ✅ | — | ✅ |
| Includes a cause that is not an Error | ✅ | ✅ | ✅ | — | ❌ | — |
| Includes a cause from another realm | ✅ | ✅ | ❌ | — | ❌ | — |
| Ends a circular cause chain | ✅ | ✅ | ✅ | — | ✅ | — |
Includes nested AggregateError entries | ✅ | ✅ | ✅ | — | ❌ | — |
| Handles a 10,000-deep cause chain | ✅ | 💥 | 💥 | — | 💥 | — |
Design Decisions
- Rebuild error-like objects, don't wrap them. An object with a string
messageis almost always an error that lost its prototype on the way (through JSON orpostMessage), so it becomes a plainErrorwith its name, stack, cause and other properties. Wrapping it in aNonErrorwould bury its message in JSON. - Only real Errors match
hasCode. It narrows toError & { code }, which would be a lie for a plain object. Convert first, withhasCode(toError(err), code), to match error-like objects. - A depth limit by default. A cause chain dozens of levels deep is almost always a bug (like an error wrapped on every retry), and printing all of it buries the useful part.
maxDepthis 32, the walk says where it stopped, andInfinityturns the limit off. - A fallback for
Error.isError, not a requirement. It is the only way to recognize an Error from another realm without running a Proxy's traps, but browsers only shipped it in 2025 (Chrome 134, Firefox 138, Safari 18.4), so older ones lack it. Without it,instanceof Errorand the[object Error]tag still recognize Errors from any realm, inside atryso a Proxy can't make them throw. The cost is that a Proxy around an Error, or an object with a forgedSymbol.toStringTag, also passes. It is read on every call, so a polyfill loaded later is used.
Non-Goals
- Serializing errors. To send an error over JSON, use
serialize-error. unknown-error rebuilds what it receives on the other side. - Error class hierarchies. It converts and inspects errors, and has no opinion on how you define them.
- Recovering the throw location of non-Errors. A thrown string has no stack. A
NonError's stack points at thetoErrorcall, the closest place that is known.
Back to the overview.