Protected support for `class`

It's not a preference. It's the rule that exists in compiled class-based languages. Consider this. Suppose "A" was "Shape", "B" was "Box", "C" was "Circle", and "A::p" was the abstract function "RenderShape". If "B" could call "C::p", then the functions in B expecting the data of a quadrilateral would instead receive data for a circle. Bad deal. Sure the example is contrived, but the reality stays the same regardless.

Another way to think of it is that a "protected" member is a private member shared specifically with a selected descendant. The shared element is therefore "effectively private" to that descendant as well as sharable to further generations. So despite the common ancestor, the shared protected member is private with respect to the inheriting class. There should be no means for another class to see that data despite inheriting the same members from the ancestor.

I'd love to see this become natively implemented. I'm fairly certain a lot of other developers would as well.

Makes sense, that could cause a problem. I agree, this may be better. This case is covered by either of my two options anyways.

I think it'd be interesting to have the ability to specify differing levels of protectedness somehow, which would allow communication across sibling classes, but still keep things hidden from public. Do any languages have that?

It's making me want to get the Firefox source and give it a try.

I know I'm way late in replying, but I've been thinking about @ljharb and his objections toward "protected" being added. I'll get to that in just sec.

That's precisely the standard approach to the class keyword used by most compiled languages.

  • private: accessible only to member functions of the declared class.
  • protected: accessible to member functions of the declared and descendant classes.
  • public: accessible to any function via the instance object.

In more recent languages, there's also something similar to...

  • internal: accessible to all functions within the same compiled module via the instance object.

@ljharb

Over the years that I've known your stance on this issue, you've been fond of saying something like this:

That's a direct quote, but you've said something similar many times. My question to you is whether or not that stance is unassailable. As you know, I believe it to be flawed with as strong a conviction as you believe it to be sound. I now have a potential way of showing you that flaw. Hopefully you'll humor me and discuss this. I'll begin here:

Your statement above implies that the "visibility" in ES is not reasonably similar to "access levels" provided by compiled languages (and other languages supporting both "class" and "protected". I find fault for 3 main reasons:

  1. While ES does indeed only support 2 levels of visibility, those visibility levels are specific to a function environment. Put simply, just because a function has access to certain data available via a certain object during a certain run of that function, does not mean that on subsequent runs of that function, the same access will be afforded. It also does not imply that other functions declared within the same scope will have that same access. This implies that the visibility you mention is already conditional. It's not simply a matter of whether something is visible or not from this point in the code, but rather whether or not it is visible from this point in the code on this run with these parameters and this environment configuration. The existence of conditional visibility is the core feature required to create a "protected" visibility.
  2. The existence of a "protected" visibility does not change the truth that given the constraints mentioned above, a member of an object will either be accessible or not. The simple fact that "protected" can be emulated in a secure fashion that would even meet with your requirements (should you allow for such thing) is by itself proof that the language can support such a concept without breaking the existing all-or-nothing visibility constraint of ES.
  3. The concept of "accessibility levels" (as I desire it implemented, at least) is not at all incompatible with "visibility", but rather a recognition that "visibility" is already conditional in ES. Taking advantage of that conditional nature is the core method for producing the "protected" functionality desired.

You mentioned before that such is not seen often in the wild. That's true. It's likely you've almost never seen it, but that is also for good reasons.

  • The abstraction is complicated to implement properly in ES.
  • There is nothing even remotely ergonomic about such an implementation.
  • Most developers do most of their development work for a company that is not likely to approve non-standard, not well-known, not major company backed libraries, even if they provide a reasonable benefit to the productivity of a developer.

There's more reasons, but those are the top 3 that I can think of. Despite this, you can already see from the traffic in the GitHub repo that there is a desire to also have "protected" visibility within class. The absence of this feature constrains the usability of class to the point that the arguments against it made by those who don't see it's utility are indeed correct. There is scarce little that provides any unique benefit to using class. Compound this with the array of footguns packed into the current "private fields" proposal and the unfortunate reality is that class does not provide the power and flexibility required by those who would otherwise take full advantage of it.

1 Like

JavaScript already supports this feature:

class MyClass {
    get field() { return undefined; }
    constructor(value) {
       super.field = value;
    }
    method() {
       super.field *= 5;
    }
    log() {
        // still accessible from within the class
        console.log(super.field);
    }
}

(Unfortunately, V8 optimizes this pattern poorly.)

I wish. Your super.field exists on the prototype. You just modified the prototype of all instances of MyClass. That is by no means the same as what protected grants you.

Hi, the ECMAScript 2022 brought support for private methods and properties using #. But still without protected support. I think that now is a good moment to implement it.

It's possible to simulate protected properties and methods pretty efficiently by building on private elements.

That's because the public/protected/private triple in many languages represents an "access level" system, which JS simply does not have. In JS, things are either public/reachable, or private/unreachable, with no middle state. Private fields are the same as closed-over variables inside a function body - private/unreachable, even via reflection. "Protected" simply does not make sense in this language - wanting it is simply a category error.

I'm using the following definitions:

Public: accessible from any code

Private: accessible only from code within the declaring class (#)

Protected: accessible only from code within the classes associated with a particular instance

Insider/"friend": accessible only from code within an enumerated list of trusted classes

By protected, I mean that given:

class A { }
class B extends A {}
class C extends B {}
class D extends A {}
const a = new A(), b = new B(), c = new C(), d = new D();

then class A methods can see the protected state of instances a, b, c, and d; class B methods can see the protected state of b and c (but not a or d); class C methods can see the protected state of c (but not a, b, or d); and class D methods can see the protected state of d (but not a, b, or c).

By insider/"friend", I mean that if class A trusts class B, then the insider state is visible to the methods of class A and class B, and no other classes or code.

I have ES2022 (#-based) implementations for both the protected and insider patterns. I can also link to my walk-through videos on YouTube if you're interested.

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.

1 Like

Thank you for the very detailed analysis and feedback, Jordan. I'm incorporating your fixes.

I understand it has limitations (I think we can agree JS is not a security-minded language), but so does protected in TypeScript, and so do WeakMaps, and bound functions, etc. and people still use those. I believe some people will still find the option and trade-offs it provides useful at some level.

If it's only me, I can live with that.

It’s in face one of the most secureable languages on the planet :-) see the Hardened JS project (caja etc). It just can’t provide security by incompatible mental models, such as “access levels”

1 Like