A JavaScript library invokes user-supplied callbacks and third-party code, so its catch blocks can receive absolutely any value. How do you turn an unknown caught value into something safe to log and rethrow, without the error path itself throwing?
answer
- the catch binding is untyped input
- instanceof breaks across realms
- brand check via Object.prototype.toString
- String() is not total
- a throw in the catch destroys the original
basics
~20 sNormalize at the boundary: check instanceof Error, fall back to Object.prototype.toString branding for cross-realm errors, and otherwise construct an Error from a defensively stringified value. Stringification must sit inside its own try, because null-prototype objects and throwing toString methods make String() throw.
solid answer
~50 sTreat the catch binding as untyped input. First decide whether it already is an Error: `err instanceof Error` covers the normal case, but it is false for errors created in another realm — an iframe, or a Node `vm` context — because each realm has its own `Error` constructor and prototype chain. The realm-independent check is the internal branding: `Object.prototype.toString.call(v) === '[object Error]'`. If it is an Error, rethrow it untouched; identity is worth preserving. If it is not, build one: `new Error(safeString(v), { cause: v })` (ES2022), keeping the original value reachable rather than flattening it into text. The subtle part is `safeString`. `String(v)` throws for an object with a null prototype or a `toString` that itself throws, and template literals and `+` throw on symbols even though `String()` handles them. So put the conversion in its own `try` with a `Object.prototype.toString.call(v)` fallback. A throw inside your catch block replaces the original failure, which is the one outcome you can never afford.
code
javascript · 19 linesfunction safeString(value) {
try {
return String(value);
} catch {
return Object.prototype.toString.call(value);
}
}
function toError(value) {
if (value instanceof Error) return value;
if (Object.prototype.toString.call(value) === '[object Error]') return value;
return new Error(safeString(value), { cause: value });
}
console.log(toError('boom').message); // "boom"
console.log(toError(undefined).message); // "undefined"
console.log(toError(Symbol('x')).message); // "Symbol(x)"
console.log(toError(Object.create(null)).message); // "[object Object]"
console.log(toError(new RangeError('r')).name); // "RangeError" (returned by identity)go deeper
Know that a catch block can receive any value, so guard before reading properties: check err instanceof Error rather than assuming err.message exists.
Explain the concrete failure modes — a null-prototype object making String() throw, symbols throwing under template literals, throw undefined breaking the property read — and write a normalizer whose conversion sits inside its own try.
Show that you have debugged this in production: name the realm problem behind a false instanceof, place the normalizer at the boundary rather than in every layer, and explain why a throw inside a catch block is the worst failure mode there is.
Decide the system-wide contract: one normalization point per entry surface, a stable logged shape so alerting and dedup keep working, and a rule that error-handling code is held to the same defensiveness as parsing untrusted input.
## The premise: a catch binding is untyped input Even a codebase that religiously throws Errors receives arbitrary values, because it calls code it does not own: user callbacks, plugins, third-party SDKs, transitively bundled dependencies. Any of them may `throw 'boom'`, `throw { status: 500 }`, or `throw undefined`. So the code at a library boundary — the place that logs, reports, wraps, or converts to a caller-facing failure — must be written as if `err` were parsed JSON from the network. ## Step 1: is it already an Error? The ordinary check is `err instanceof Error`. That works within one realm. It fails across realms. A **realm** is an independent set of intrinsics: a global object with its own `Error`, `Array`, `Object` and prototypes. An iframe is a realm; so is a Node `vm` context. An Error constructed in an iframe has *that* iframe's `Error.prototype` on its chain, so `instanceof Error` against your realm's constructor is `false` even though the object is unmistakably an error. The realm-independent test uses the internal slot rather than the prototype chain: ```js Object.prototype.toString.call(new Error('x')); // '[object Error]' Object.prototype.toString.call({ message: 'x' }); // '[object Object]' ``` Error objects carry an internal `[[ErrorData]]` slot, and `Object.prototype.toString` reports `'[object Error]'` for anything that has it — including subclasses, and including errors from other realms. Use `instanceof` as the fast path and this as the fallback. Be aware of what neither check does: an object that merely *has* a `message` property is not an Error, and treating it as one is how you end up logging `undefined` where a stack should be. ## Step 2: stringify defensively This is where naive normalizers break. `String(value)` looks total. It is not: ```js String(Object.create(null)); // TypeError: Cannot convert object to primitive value String({ toString() { throw new Error('nope'); } }); // throws the inner error ``` A null-prototype object has no `toString` and no `Symbol.toPrimitive`, so conversion fails outright. An object with a hostile or buggy `toString` propagates whatever that method throws. Both are values a plugin can hand you. Symbols invert the usual intuition: `String(sym)` is explicitly allowed and yields `'Symbol(desc)'`, while `` `${sym}` `` and `'' + sym` throw a TypeError. So string concatenation is the *less* safe idiom, not the more forgiving one. The defensive form wraps the conversion: ```js function safeString(value) { try { return String(value); } catch { return Object.prototype.toString.call(value); // never throws } } ``` `Object.prototype.toString.call(v)` is total for every value — it reads an internal tag rather than invoking user code — which makes it the right last resort. `JSON.stringify` is tempting for objects, and it does produce more detail, but it throws on circular structures and on BigInt values, and it silently returns `undefined` for functions and symbols. If you use it, it belongs inside the same `try`. ## Step 3: wrap without destroying ```js function toError(value) { if (value instanceof Error) return value; if (Object.prototype.toString.call(value) === '[object Error]') return value; return new Error(safeString(value), { cause: value }); } ``` Two decisions matter here. Real errors are returned **by identity** — no wrapping — so their type, properties and stack survive untouched. Non-errors get a fresh Error whose message is a best-effort rendering, with the original value kept reachable via the ES2022 `cause` option rather than discarded. A structured logger can then inspect the original even when its string form was useless. ## Step 4: never let the error path throw Everything above serves one rule: a throw inside a catch block **replaces** the in-flight failure. If your logging line does `err.message.toUpperCase()` and `err` is `undefined`, the original failure is gone forever and what propagates is a TypeError from your own diagnostics — the most expensive class of bug to trace, because the evidence destroyed itself. The same applies to a formatter that dereferences `err.response.data`, or a serializer that hits a circular reference. So: no unguarded property reads on the caught binding, no string interpolation of it, no eager serialization outside a `try`. ## Where to put the normalizer At the outer edge only — the request handler, the job runner, the public API surface of your library. Normalizing deep inside means intermediate layers receive a wrapped error instead of the real one and lose the ability to branch on it. Internally, rethrow bare; convert once, where the failure stops. ## What to say in the interview The insight being probed is that error handling is itself code that can fail, and that the type of a catch binding is genuinely unknown. Naming the realm problem and the `String()` failure modes shows you have debugged this rather than read about it.
- Why is `Object.prototype.toString.call(v) === '[object Error]'` more reliable than `v instanceof Error`?`instanceof` walks the prototype chain and compares against *your* realm's `Error.prototype`. An error created in an iframe or a Node `vm` context has a different realm's prototype, so the check is false. `Object.prototype.toString` reports on the object's internal error slot instead, which is realm-independent and also covers subclasses. Use `instanceof` first for speed, this as the fallback.
- Would `JSON.stringify(err)` be a better way to render an unknown thrown value?It gives more structure for plain objects, but it is not total: it throws on circular references and on BigInt, returns undefined for functions and symbols, and — importantly — omits `message` and `stack` for real Errors because they are non-enumerable. So it is a supplement inside the same try block, never the primary conversion.
- Should the normalizer run at every layer, or only at the boundary?Only at the boundary where the failure stops — a request handler, a job runner, your library's public surface. Normalizing deeper means every intermediate layer sees a wrapper instead of the real error and can no longer branch on its type or properties. Inside, rethrow bare; convert once, at the edge, right before logging or reporting.
- What actually goes wrong if the logging code inside a catch block throws?The new error replaces the one in flight. The original failure — the thing you were trying to record — is gone, and what propagates upward is a TypeError from your diagnostics, pointing at the logger rather than the fault. It is the worst possible failure mode because the evidence destroys itself, which is why every read of the caught binding must be guarded.
saying these in an interview costs you the question
- Assumes every caught value has .message and .stack
- Uses instanceof Error as the only error check
- Interpolates the caught value straight into a template literal
- Believes String() can never throw
- Treats any object with a message property as an Error