1.1. TC39 - Error.isError proposal
The following Error.isError proposal was picked during the lecture by the class as an interesting one to look more in depth. Here we provide a short overview and some context.
In 2015, TC39 knowingly made Object.prototype.toString forgeable. Meaning, it could be overridden and faked. Committee members knew it was a problem and agreed on a replacement technique for brand-checking. The replacement worked for every builtin except one - Error.
The exception was spotted almost immediately — by the same person who had raised the original concern, who proposed the fix that same year. Everything after that is a story about how long it takes to ship a one-line function.
Two facts before we start
You can throw anything.
throw 42;
throw "oops";
throw { code: "E_BAD", message: "looks like an error!" };
So catch (e) hands you an unknown value, and "is this actually an Error?" is a real runtime question. Every logger, error reporter, serializer and test framework has to answer it, and the rest of this article is about how hard that turned out to be.
Objects have two kinds of identity.
| Reachable from JS? | Forgeable? | |
|---|---|---|
| Structure — properties, symbols, prototype chain | yes | trivially |
Internal slots — [[ErrorData]], [[MapData]], [[DateValue]], [[Promise]] | no | never |
Internal slots are engine-owned state attached at construction time. You cannot read them, write them, or fake them. Asking "does this object have the slot?" is called a brand check, and it is the only honest answer to "is this a real X?"
The language mostly refuses to let you ask. Array.isArray (ES5) was the first brand check exposed as an actual API — keep it in mind, because Error.isError is the same patch applied twenty years later, and everything in between follows from that refusal.
1. 2005–2015: the golden age of Object.prototype.toString
In ES5, every object carried an internal [[Class]] property, and Object.prototype.toString printed it:
Object.prototype.toString.call([]); // "[object Array]"
Object.prototype.toString.call(new Date()); // "[object Date]"
Object.prototype.toString.call(new Error()); // "[object Error]"
[[Class]] was set by the engine and could not be set from JavaScript. That made this one line the closest thing the language ever had to a universal, unforgeable type oracle — and the whole ecosystem built on it. Here is Node's, essentially unchanged from 2013:
// node/lib/util.js
function isError(e) {
return Object.prototype.toString.call(e) === "[object Error]" || e instanceof Error;
}
Not a hack. For a decade this was the correct answer, and it deserves to be remembered that way before we watch it break.
The half that was already broken
Notice the || e instanceof Error at the end. That half was doing something different, and it had never worked properly.
instanceof has never been a brand check. The ES5 algorithm reads F.prototype as an ordinary property, then walks the left operand's prototype chain looking for that exact object by identity. Two mutable inputs, no engine-owned state consulted — so both failure modes fall straight out of the definition.
The false positive:
const fake = Object.create(Error.prototype);
fake instanceof Error; // true ← the chain says yes, the engine says no
And the famous false negative:
// Node
const vm = require("node:vm");
const err = vm.runInNewContext('new TypeError("boom")');
err instanceof TypeError; // false
err instanceof Error; // false
// Browser
const iframe = document.querySelector("iframe"); // same-origin
const err2 = iframe.contentWindow.eval('new Error("x")');
err2 instanceof Error; // false
err2 instanceof iframe.contentWindow.Error; // true — the old, awful workaround
A realm is one complete global environment: a global object plus its own full set of intrinsics — its own Object, its own Array, its own Error. Two realms means two Error.prototype objects that are structurally identical and referentially different, and instanceof compares by reference. That is the entire bug.
| Browser | Node |
|---|---|
same-origin <iframe> | vm.createContext() / runInNewContext() |
window.open() popups | worker_threads (separate isolate) |
| Web Workers (separate agent) | jsdom / jest-environment-jsdom |
ShadowRealm (proposal) | any --experimental-vm-modules sandbox |
Two things people get wrong. postMessage does not have this problem — structured cloning re-creates the error in the receiving realm, so instanceof works there. The breakage needs a direct reference to a foreign object: synchronous iframe access, vm return values. And you can hit the identical bug with no realms at all — two copies of a package in node_modules give you two distinct CustomError classes and the same identity-versus-shape failure.
Every Jest user has met the jsdom variant of this: expect(fn).toThrow(TypeError) failing because the error was born in the other context. The workaround — err instanceof Error || err instanceof iframe.contentWindow.Error — doesn't generalise past the realms you happen to know about.
And the branch nobody could explain
The tag check was solid and instanceof was leaky, so libraries used both, plus a duck-typing fallback. lodash 4's _.isError is roughly "recognised tag, or a non-plain object with string name and message":
class ApiResponse {
constructor() {
this.name = "x";
this.message = "y";
}
}
_.isError(new ApiResponse()); // true ← false positive
Error.isError(new ApiResponse()); // false
Sentry's browser SDK had the same shape. But look closely at what tags those libraries accept, because there's a third one in there that has nothing to do with any of the above:
tag === '[object Error]' || tag === '[object DOMException]' || /* duck-typing fallback */
Park that DOMException branch. It looks like a wart. It turns out to be the thread that ties the whole story together.
2. ES6 adds @@toStringTag
ES6 deleted [[Class]] outright and redefined Object.prototype.toString: run a fixed sequence of internal-slot checks to derive a builtin tag ([[ErrorData]] → "Error", [[DateValue]] → "Date", [[Call]] → "Function"), then look up @@toStringTag on the object — and if it's a string, it wins.
class User {
get [Symbol.toStringTag]() {
return "User";
}
}
Object.prototype.toString.call(new User()); // "[object User]"
The motivation was real, and it's worth stating fairly. ES6 was adding Map, Set, Promise, Symbol, class syntax and subclassable builtins, and [[Class]] was a closed list that only the specification could extend. The extensible-web position of the era held that user code ought to be able to describe itself exactly the way builtins do. And nobody had ever promised that Object.prototype.toString was unforgeable — the ecosystem had simply relied on it as though someone had.
The trade, stated plainly: an unforgeable mechanism became an overridable one.
Symbol.toStringTag has no tc39/proposal-* repo, and that isn't an oversight. It was part of ES6 core, drafted by Allen Wirfs-Brock directly in the main specification draft. The staged process you know — Stage 0 through 4, one repo per feature — was only formalised around 2013–2014 and applied to post-ES6 work.
Which is why the paper trail for the argument in the next section is a Bugzilla bug filed against "Draft for 6th Edition" rather than GitHub issues. If you go hunting for the history of an ES6 feature in the proposals org and find nothing, you're looking in the wrong archive.
(@@name is spec shorthand for Symbol.name; it was never real syntax. Modern ecma262 writes %Symbol.toStringTag% instead — but @@ is what every source from this era uses, so it's what this article uses.)
3. January 2015: the bug report, the meeting, the mitigation, the hole
The bug report
On 2 January 2015, Jordan Harband filed draft bug #3506: "@@toStringTag protected builtins list not complete?"
Crucially, the draft still had anti-spoofing at that point. A step 17b listed protected legacy tags — "Arguments", "Array", "Boolean", "Date", "Error", "Function", "Number", "RegExp", "String" — and earlier drafts had gone as far as prefixing ~ to impostor tags to keep the legacy names honest. Harband's question was modest: shouldn't "Math", "JSON" and "Object" be protected too, since ES5 code brand-checks those as well?
Allen Wirfs-Brock's reply, in substance: protecting them would require inventing branding slots for Math and JSON, nobody plausibly brand-checks those, and he was inclined to forgo the complexity and let it slide.
Then the committee went considerably further than the bug had asked.
The meeting
Harband's own summary of the 28 January plenary, posted back to the bug:
- Checks that call a non-side-effecty, non-generic builtin method relying on an internal slot, inside a
try/catchwere reliable in ES3 and will stay reliable in ES6. - So security-conscious code should migrate to those and stop using
Object.prototype.toString. - V8 would keep
@@toStringTagbehind a flag for a full two months to give libraries time to update. - And since anyone who makes a value masquerade as a builtin intends that outcome, step 17b — the anti-spoofing list — would be removed entirely.
That last bullet is one of the very few outright deletions in this story. It is also the one that mattered.
The mitigation, spelled out
Some builtin methods are generic — they run on anything, because all they do is read ordinary properties:
Error.prototype.toString.call({ name: "Boom", message: "hi" }); // "Boom: hi" — happily
Array.prototype.map.call({ length: 2, 0: "a", 1: "b" }, (x) => x); // ['a', 'b']
Others are non-generic — the spec's first step is "if this lacks internal slot X, throw a TypeError." They physically cannot run on an impostor, because the slot is engine-owned:
Map.prototype.has.call({}, 1); // TypeError: incompatible receiver
Map.prototype.has.call(new Map(), 1); // false — no throw
So you use the exception as the answer. The return value is irrelevant; the throw is the oracle:
function isMap(v) {
try {
Map.prototype.has.call(v, undefined);
return true;
} catch {
return false;
}
}
isMap(new Map()); // true
isMap({ has() {}, [Symbol.toStringTag]: "Map" }); // false — the tag lies, the slot isn't there
isMap(new Proxy(new Map(), {})); // false — a Proxy has no slots of its own
"Non-side-effecty" is in that sentence for a reason: you need a method that checks the slot and then does something harmless. Map.prototype.set mutates. Map.prototype.forEach runs user code. Promise.prototype.then allocates and schedules a microtask — and it happens to be the only slot-checking method on Promise.prototype, which is why promises ended up with a check that is reliable but not free.
Applied across the language, that gives you:
| Type | Side-effect-free brand check |
|---|---|
| Map / Set | Map.prototype.has.call(v) / Set.prototype.has.call(v) |
| Date | Date.prototype.getTime.call(v) |
| RegExp | Object.getOwnPropertyDescriptor(RegExp.prototype, 'source').get.call(v) |
| Number / String / Boolean box | Number.prototype.valueOf.call(v), etc. |
| ArrayBuffer | Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength').get.call(v) |
| Array | Array.isArray(v) — a real API already |
| Error | — nothing — |
Before we get to that last row: the technique has two genuinely excellent properties. It's unforgeable, since no property, symbol, prototype or Proxy trap gets a fake past it. And it's realm-agnostic, because a slot is a slot regardless of which global made the object — Map.prototype.has.call(iframeMap) doesn't throw even though iframeMap instanceof Map is false. One technique, both of §1's problems solved at once.
It is also miserable in practice. You have to capture the method reference before any user code runs, or someone reassigns Map.prototype.has and your oracle quietly becomes userland code — this is the entire reason the get-intrinsic / call-bind / es-abstract ecosystem exists, hundreds of packages whose only job is holding onto pristine intrinsics. And try/catch around a throwing call is slow, deoptimising, and a wall of boilerplate per type.
The hole
Every method on Error.prototype is generic. toString reads this.name and this.message off any object at all, and hands back a string:
Error.prototype.toString.call({ name: "X", message: "y" }); // "X: y" — no throw, no signal
There is no getMessage(), no accessor, nothing that consults [[ErrorData]]. As the proposal README puts it: the internal slot for Error instances and their subclasses is consulted only inside Object#toString itself — precisely the mechanism the committee had just made spoofable.
So the safety net had exactly one hole in it, and errors fell through:
// Node — false positive
const util = require("node:util");
const obj = { name: "Error", message: "boom" };
util.isError(obj); // false
obj[Symbol.toStringTag] = "Error";
util.isError(obj); // true ← forged
Error.isError(obj); // false ← correct
// Browser — false negative: a real error made invisible
const real = Object.assign(new Error("real"), { [Symbol.toStringTag]: "Potato" });
Object.prototype.toString.call(real); // "[object Potato]"
Error.isError(real); // true
Node documented this in 2016 rather than fixing it — the docs stated outright that a manipulated @@toStringTag could produce an incorrect result from util.isError. The note stood for nine years, because it could not be fixed from userland: a fully correct isError wasn't merely awkward to write, it was impossible, and the shims that got closest had to deep-clone the value through structuredClone just to probe the slot indirectly.
4. DOMException: the wart explained
Now back to that stray branch from §1.
DOMException is the error type of the web platform, not of the JavaScript language — defined in WebIDL, not ECMA-262. You get one from fetch aborts, IndexedDB, navigator.clipboard, a failed structuredClone, a bad selector:
try {
document.querySelector("###");
} catch (e) {
e.name; // "SyntaxError" ← looks like a JS error
e.constructor.name; // "DOMException" ← isn't one
e instanceof Error; // false
}
It is not a subclass of Error and never has been, so every library had to special-case it by tag string. A DOM error is obviously an error to a human and obviously not one to instanceof.
The connection to this proposal runs both ways. Historically, in April 2017 Harband tried to fix it on the DOM side (whatwg/dom#434), and Anne van Kesteren's response was that TC39 should solve it for all objects the way isArray does — just add a method. The web platform handed the problem back to the language, eight years before it was solved.
Semantically, once Error.isError started advancing it had to answer what DOMException returns. Making it a genuine Error subclass was judged too risky for web compatibility. So instead — and this is the reflex worth naming — WebIDL added the [[ErrorData]] slot to DOMException (whatwg/webidl#1421, December 2024) rather than repairing the inheritance graph:
new DOMException() instanceof Error; // false — unchanged, forever
Error.isError(new DOMException()); // true — it has the slot now
Don't fix the hierarchy, bolt on the marker. It's the same instinct that produced @@toStringTag instead of repairing [[Class]], and that is about to produce Error.isError instead of repairing instanceof.
It wasn't free either. Engines had to implement it separately: WebKit filed a bug in May 2025 because its Error.isError used an internal isErrorInstance() check that DOMException's object structure didn't satisfy, and Node before v24.3 shipped an Error.isError that got DOMException wrong.
5. 2015–2025: ten years through the stage process
The API is trivial. That is the point.
Error.isError(new Error("x")); // true
Error.isError(new TypeError("x")); // true
Error.isError(new DOMException()); // true
Error.isError({ name: "Error", message: "x" }); // false
Error.isError(Object.create(Error.prototype)); // false
Error.isError(iframeWindow.Error.prototype); // false
It's a plain slot test: return true iff the argument is an Object with [[ErrorData]]. MDN describes it as a branded check for a private field initialised by the Error() constructor — the same mechanism as Array.isArray().
| When | What |
|---|---|
| Jan 2015 | Bug #3506; the committee removes the anti-spoofing list |
| 2015 | Harband first presents the idea to TC39 |
| 2016 | Node documents the util.isError hole instead of fixing it |
| Apr 2017 | whatwg/dom#434 — punted from the DOM back to TC39 |
| Jun 2024 | Stage 2 |
| Dec 2024 | Stage 3; WebIDL PR gives DOMException the slot |
| Mar 2025 | Chrome / Edge 134 |
| Apr 2025 | Firefox 138 |
| May 2025 | Stage 4, 108th meeting, A Coruña; repo archived |
| 2026 | Published in ES2026 |
Node has it from v24 (V8 13.4). Safari still hasn't shipped it, which is why it is not yet Baseline — so the shim, with all its documented caveats, is probably still sitting in your dependency tree.
Why does a one-line function take a decade? Because TC39 doesn't ship functions, it ships consensus plus coordination: a change in another standards body, four engine implementations, test262 coverage, and web-platform-tests for the DOMException case. The API was never the hard part.
6. What it fixes, what it doesn't, what breaks
Before:
function isRealError(v) {
if (v instanceof Error) return true; // misses cross-realm
const tag = Object.prototype.toString.call(v); // spoofable
return tag === "[object Error]" || tag === "[object DOMException]";
}
After:
Error.isError(v);
It answers one bit. There is still no cross-realm way to ask "is this a TypeError?" or "is this my ValidationError?" For subclass dispatch you are back to conventions:
Error.isError(e) && e.name === "TypeError"; // portable, still spoofable
Error.isError(e) && e.code === "E_VALIDATION"; // your own convention
And it is strictly a slot check, so things that used to pass now correctly fail — which will surface in test fixtures and mock libraries before it surfaces anywhere else:
Error.isError(Object.create(Error.prototype)); // false — common in fixtures
Error.isError({ name: "Error", message: "mock" }); // false — common in mocks
Error.isError(new Proxy(new Error(), {})); // false — Proxies have no slots
That's usually the bug you wanted to find, but it is a behaviour change, and it will find your test suite first.
Meanwhile instanceof remains exactly as broken as it was in 1999. Nothing in this story fixed it; a second, better mechanism now sits beside it. That is how JavaScript moves — correct APIs accrete next to incorrect ones, the incorrect ones never leave because someone's shipped code depends on them, and your mental model has to carry both layers forever.
Which is also why it isn't over. In January 2026 a TC39 side discussion revived the question of whether DOMException could simply inherit from Error properly after all (whatwg/webidl#1107). Eleven years in, the correct fix is still only being asked about.
Sources
- tc39/proposal-is-error — README rationale, archived at Stage 4
- ES6 draft Bugzilla #3506 — the January 2015 exchange, primary source
- esdiscuss: Configurability of @@toStringTag for Built-ins — Wirfs-Brock, Eich, Waldron on design intent
- MDN:
Error.isError() - whatwg/webidl#1421 —
[[ErrorData]]onDOMException - whatwg/dom#434 — the 2017 attempt, and van Kesteren's redirect to TC39
- Igalia: Summary of the May 2025 TC39 plenary — Stage 4
- web-features:
is-error— current support status error.iserroron npm — the polyfill and its documented limits