Those definitions are precise, and that's genuinely useful here - because it makes clear which half of the protected one is implementable.
Protected: accessible only from code within the classes associated with a particular instance
Split that in two:
- accessible from code within those classes - implementable, and your pattern does it.
- only - not implementable. And that word is the entire content of "protected". Without it, what you've built is sharing, not an access level.
Here is the published protected-js pattern, unmodified, with the exact B/C hierarchy from its own demo.js. The attacker is ordinary outside code holding a constructed instance:
// 2 lines. `static __protected` is a public, writable object which is the
// prototype of every instance's protected-state object.
let s;
victim.constructor.__protected.logState = function () { s = this; };
victim.callProtectedLogger(); // the library itself then calls this.#_.logState()
s.base; s.propB; s.propC; // read protected state the outsider never wrote
s.__this === victim; // true — and pivot back to the instance
s.propB = 'PWNED'; // write; the class's own reads observe it
And the general one, which doesn't care about the class, the hierarchy, or __protected at all. [_GET] is a public method that iterates a Set with for...of — a dynamic Symbol.iterator lookup — so you just supply the iteration result:
function steal(obj) {
let state;
const real = Set.prototype[Symbol.iterator];
Set.prototype[Symbol.iterator] = function* () { yield (s) => { state = s; }; };
try { obj[Symbol.for('jsProtectedGet')](); } finally { Set.prototype[Symbol.iterator] = real; }
return state;
}
That returns the genuine #_ — object identity, not a copy — for C, for Sub, and for a bare new Base(). It needs nothing but an already-constructed instance, invokes no real subscriber, restores the global it borrowed, and survives Object.freeze(Base.__protected). state.logState() then passes the pattern's own if (_thys !== thys.#_) throw new Error('Unauthorized') check — the implementation certifies the outsider's handle as authentic.
Also worth noting: test/integration.test.js, "protected state cannot be subverted after construction", calls sub(new_) and discards the return value. The subscriber is (p) => this.#_ ||= p, and ||= short-circuits, so that discarded return value is the protected state. The test executes the leak and concludes the opposite:
const subs = new Set(); victim[Symbol.for('jsProtectedSub')](subs);
const state = [...subs].map((f) => f()).find(Boolean); // no argument, so nothing is overwritten
To save the patch cycle: these aren't bugs. I worked through the obvious hardening - freeze the statics, module-local Symbol() instead of Symbol.for(), defineProperty for __this, drop __this, new.target.__protected, block-body subscribers, freeze every prototype - and there is no fixed point. Symbol() buys nothing, because the key is recoverable via getOwnPropertySymbols off the public prototype chain. Dropping __this adds a hole, since __this is the only authentication a protected method has. catch (_) {} turns out to be load-bearing: remove it and the pattern stops working, because every intermediate [_GET]() necessarily throws on the not-yet-installed derived #_. And the last one cannot be closed at all — the moment the shared object is used, it crosses a call boundary resolved at runtime (console.log, Object.keys, any callback), and a single global write catches it there.
Which is the actual point, and it isn't about this implementation.
JS's encapsulation primitive is unreachability, and reachability is a property of the object graph. protected is a property of the code that is asking. JS has no runtime notion of caller identity — nothing can ask "which class body is this call lexically inside?". #x works precisely because it never asks: it is resolved lexically, at parse time, in exactly one class body, and no channel to it exists. That is why private fields do satisfy "only".
Protected differs in kind, not degree. #_ in Base and #_ in Sub are different names, so the shared thing must be distributed between them at runtime - and every runtime channel in JS is reachable by anyone who can run code in the realm. That's the whole trade: unreachable but unshared, or shared and therefore reachable. There is no third state to build on, which is why no amount of efficient simulation gets you there. The leak isn't in the mechanism; it's in the requirement.
Your insider/"friend" pattern is the interesting confirmation of this. That one does work - because it is capability-based: possession of a token, which is exactly reachability. It maps cleanly onto what the language actually has. The one that doesn't map onto reachability is the one that doesn't survive contact. And a module-scoped WeakMap closed over by the classes that should share the state gives you every guarantee protected-js reaches for with none of this surface - the honest description of which is "private state, shared lexically", not an access level.
So it isn't a category error because the ergonomics are bad, or because nobody has been clever enough yet. It's a category error because "only" is a claim about callers, and JS has no callers to interrogate.