Skip to main content

1.1. TC39 - Error.isError proposal

Not required part of the course

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 chainyestrivially
Internal slots — [[ErrorData]], [[MapData]], [[DateValue]], [[Promise]]nonever

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
Realms, in one paragraph

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.

BrowserNode
same-origin <iframe>vm.createContext() / runInNewContext()
window.open() popupsworker_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.

This was never a "proposal"

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/catch were 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 @@toStringTag behind 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:

TypeSide-effect-free brand check
Map / SetMap.prototype.has.call(v) / Set.prototype.has.call(v)
DateDate.prototype.getTime.call(v)
RegExpObject.getOwnPropertyDescriptor(RegExp.prototype, 'source').get.call(v)
Number / String / Boolean boxNumber.prototype.valueOf.call(v), etc.
ArrayBufferObject.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength').get.call(v)
ArrayArray.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().

WhenWhat
Jan 2015Bug #3506; the committee removes the anti-spoofing list
2015Harband first presents the idea to TC39
2016Node documents the util.isError hole instead of fixing it
Apr 2017whatwg/dom#434 — punted from the DOM back to TC39
Jun 2024Stage 2
Dec 2024Stage 3; WebIDL PR gives DOMException the slot
Mar 2025Chrome / Edge 134
Apr 2025Firefox 138
May 2025Stage 4, 108th meeting, A Coruña; repo archived
2026Published 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​