-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
TC39 expertise wanted for "protected state" in web classes #1341
Comments
Seems like that would work in terms of information flow (EDIT: it's hackable, but it's fine if non-hack-ability isn't a design goal), but it's a little clunky. Maybe it could all get wrapped up in a decorator, that you apply to a private field declaration to magically transform it into the private internals getter? |
We're not concerned about the authoring experience for the authors of Or do you see a way to make that nicer using decorators? |
Right, I meant it seems a little clunky for custom elements authors, to have to set this // This would be implemented by the browser, but shown here in JS for readability
class HTMLElement {
#protectedState = 0;
// Decorator for a field declaration
static internals(desc) {
assert(desc.kind === "field");
assert(desc.placement === "own");
assert(desc.initializer === undefined);
assert(typeof desc.key === "object"); // PrivateName
return {
kind: "method",
placement: "own",
key: desc.key,
descriptor: {
get() { return this.#protectedState; }
set(v) { this.#protectedState = v; }
}
};
}
}
// This is the subclass
class CustomElement extends HTMLElement {
@HTMLElement.internals #internals;
// Public methods can use this.#internals as appropriate
increment() {
this.#internals = this.#internals + 1;
}
}
const ce = new CustomElement();
ce.increment(); // will manipulate the private state Note that you could make |
Very interesting, thank you! Do you think that's the pattern you (and possibly TC39) would recommend for future web platform and Ecma-262 "protected state" scenarios? |
I don't know whether TC39 will recommend anything in particular, but I've been recommending this pattern to folks whenever they ask about protected state in class features (which comes up a lot in the issue tracker, as well as at TC39 meetings). See https://github.com/tc39/proposal-decorators/blob/master/friend.js for some more mini-frameworks for protected-ness that you could play with (there's some vague talk about eventually making a decorator standard library, which presumably could consider adding these), but I don't see any problems with using decorators in a one-off way as in the above code sample. |
Some points brought up in IRC about the original solution, transcribing here:
The decorators based approach does not have either of these drawbacks. It just has the drawback of blocking on decorators, which ... well, I'll stop there. |
Can't an adversary just do HTMLElement
.internals({ kind: 'field', placement: 'own', initializer: void 0, key: {} })
.descriptor
.get
.call(OBJ) ? This is hard to avoid.
You can solve that by checking having |
Oh, right. This is exactly what Ron was proposing that we try to guard against by making less powerful private keys... But I am not sure exactly how that could work to prevent this. |
One other possible pattern: class HTMLElement {
#protectedState = 0;
#internals = {
set: (x) => this.#protectedState = x,
get: () => this.#protectedState
};
constructor({ internals } = {}) {
if (internals != null) {
internals.get = #internals.get;
internals.set = #internals.set;
}
}
}
// This is the subclass
class CustomElement extends HTMLElement {
#internals;
constructor() {
let internals = {};
super({ internals });
this.#internals = internals;
}
// Public methods can use this.#internals as appropriate
increment() {
this.#internals.set(this.#internals.get() + 1);
}
} (There are other possible designs along these lines, like passing an Pros:
class Intermediate extends HTMLElement {
#internals;
constructor({ internals = {} }) {
super({ internals });
this.#internals = internals;
}
}
class FinalDerived extends Intermediate {
#internals;
constructor() {
let internals = {}; // note: intentionally not exposing this to any further derived classes. you can instead copy the pattern in Intermediate precisely, if you want to allow further extension.
super({ internals });
this.#internals = internals;
}
} Cons:
That last point is kind of an interesting one - in the design of this feature, is it acceptable if anyone who constructs a Foo has access to that particular instance of Foo's internal state? (I don't think it's avoidable if you allow further subclassing, because constructing a Foo is not distinguishable to subclassing Foo and constructing the subclass.) |
Yeah, that was one of the alternatives we considered: WICG/webcomponents#758 (comment). The general thinking was that it was "too weird", but if TC39 told us it was the right way to do protected, that would certainly change folks' minds.
I think the answer is "we would prefer to avoid it if possible". Thus the design where I agree it seems to be in conflict with allowing further subclassing. Frustrating. |
Maybe we can solve this for custom elements specifically, rather than protected data generally--not proving that the access was actually inside the class, but that the access was set up before // This would be implemented by the browser, but shown here in JS for readability
// Check that the decorator is only called once, and only before customElements.define
let showProtected = new WeakSet();
function registerProtected(constructor) {
if (showProtected.has(constructor)) throw new TypeError;
if (constructor is registered as a custom element) throw new TypeError;
showProtected.add(constructor);
}
class HTMLElement {
#protectedState = 0;
// Decorator for a field declaration
static internals(desc) {
assert(desc.kind === "field");
assert(desc.placement === "own");
assert(desc.initializer === undefined);
assert(typeof desc.key === "object"); // PrivateName
let constructor;
function check(receiver) {
let definition = receiver's custom element definition;
if (definition's constructor !== constructor) throw new TypeError;
if (!showProtected.has(constructor)) throw new TypeError;
}
return {
kind: "method",
placement: "own",
key: desc.key,
descriptor: {
get() {
check(this);
return this.#protectedState;
}
set(v) {
check(this);
this.#protectedState = v;
}
}
finisher(klass) {
registerProtected(klass);
constructor = klass;
}
};
}
}
// This is the subclass
class CustomElement extends HTMLElement {
@HTMLElement.internals #internals;
// Public methods can use this.#internals as appropriate
increment() {
this.#internals = this.#internals + 1;
}
}
customElements.define(CustomElement, "custom-element");
const ce = new CustomElement();
ce.increment(); // will manipulate the private state Maybe this solution could work for any class which would need to be passed to some sort of function before it's instantiated (e.g., it's required to use some kind of class decorator, c.f. tc39/proposal-decorators#161), but not for arbitrary subclasses. |
Can y'all explain why protected state doesn't work out of the box for this type of thing? It feels like a failure of that proposal that it doesn't. |
@saambarati the proposal is for private and public state; not for "protected" state. |
@ljharb Sorry, I meant the private state proposal. It feels like this is something that should be built into that proposal, which is to have fields only visible to things in the class hierarchy. |
@saambarati See tc39/proposal-private-fields#11; protected has consistently been considered a possible follow-on proposal (that many are skeptical will ever be viable as a first-class mechanism, myself included), and can be handled with decorators in ways that (if i recall) https://github.com/tc39/proposal-private-fields/blob/master/DECORATORS.md#protected-style-state-through-decorators-on-private-state documents. |
@ljharb Thanks for sharing that. Why are you (and others) skeptical that there can’t be a first class solution here? I understand the limitations of protected state in a language dynamic in the ways JS is. However, this does really feel like something useful and worth having. It’d be nice if a user could guarantee that once a particular object is created, it has certain fields only accessible from within its class hierarchy. FWIW, from the alternatives proposed here, C++ style out params feels the least clunky to me even though it’s not a common pattern in JS APIs. |
There are several reasons why the class fields proposal doesn't include protected state (apologies for the length of this explanation).
// Actually built-in to the browser
class HTMLElement {
protected #internals;
}
// Module defining an encapsulated component
class Component extends HTMLElement { /* ... no use of #internals ... */ }
customElements.define('comp-onent', Component);
// Another module that's not supposed to know about Component's #internals
let el = document.createElement('comp-onent');
class Attacker extends HTMLElement {
getInternals() { return this.#internals; }
}
let internals = Attacker.getInternals.call(el); // This just works!
Note, in the class fields repository, many people have raised the issue that protected state would need a different sigil other than |
littledan covered the main reasons above. In addition, we'd prefer to have a simple solution, and adding protected would significantly increase complexity. |
I'm not at all in favor of adding "protected" visibility for properties, but, playing devil's advocate... without it, one of the most important concepts underlying class inheritance -- polymorphism and method overriding -- is rather neutered. Promises are a perfect example. Subclassing a promise but not being able to access its private slots severely restricts what things you can do in a promise subclass. The internal state of a promise is exactly the kind of state that should actually be "protected" instead of "private", so that a subclass could extend behavior around it. Unfortunately, protected visibility is sort of antithetical to the concept of a prototypal inheritance -- where everything is either public (and inherited) or private (lexical, etc) and not. It is strange to me that we added |
@littledan Did you mean But no, that shouldn't work. It would be the same as calling
In Try Maybe someone can do some very crazy monkey patching of |
@waldemarhorwat Are you sure it would be that significant? My whole |
@trusktr it seems your library actually runs into the same problems... const T = lowclass(({ Protected }) => class {
constructor() {
Protected(this).v = 'T';
}
});
const t = new T();
function extractTheDeets(V) {
const S = lowclass(({ Protected }) => class extends V.constructor {
constructor() {
super();
const original = Object.getPrototypeOf(V);
Object.setPrototypeOf(V, S.prototype);
const p = Protected(V);
Object.setPrototypeOf(V, original);
return p;
}
});
return new S();
}
extractTheDeets(t); for those interested in how lowclass works, it looks kinda like this: const weak = new WeakMap();
function hasInstance(O, C) {
if (typeof O !== 'object' && typeof O !== 'function') {
return false;
}
const P = C.prototype;
if (typeof P !== 'object' && typeof P !== 'function') {
throw new TypeError();
}
while (true) {
O = Object.getPrototypeOf(O);
if (O === null) {
return false;
}
if (P === O) {
return true;
}
}
}
function lowclass(fn) {
const Class = fn({
Protected(instance) {
if (hasInstance(instance, Class)) {
if (weak.has(instance)) {
return weak.get(instance);
}
const s = {};
weak.set(instance, s);
return s;
}
throw new TypeError('invalid access');
},
});
return Class;
} |
@devsnek Nice, glad to receive some input on it! That trick works with I could fix it with a monkey patch of I could also delete the Another thing I can do is provide a The issue would be entirely nonexistent in the native implementation. |
the only alternative would be an internal chain of inheritance, but that seems like a confusing path to take, given that there is already a visible chain of inheritance via prototypes. |
@devsnek -- there was a TC39 proposal several years back which was basically copy-on-write of "inherited" private slots. So the parent has a private slot with a value, child gets copy of it at instantiation, then every time it's changed in parent, the new value is copied to the child (and vice versa). I was pretty weary of that idea, because it creates an entirely separate path for data to flow from object to object. I guess you could label that copy-on-write as "an internal chain of inheritance". But I'm pretty sure something like that is the only likely approach for private visibility. |
@getify Another way to do it is "access mode" or "reference mode". Under this idea, private and protected fields all simply live on the one prototype chain, are a interoperable with all existing libs (f.e. lodash, underscore, etc). I'm sure there's other ways! I don't like the current private fields, and I think it is premature for it to be in stage 3. Private fields jumped from stage 1 to stage 3 in 2 months. |
@devsnek Given that JavaScript engines have the advantage that they can track information while keeping it inaccessible from JavaScript, wouldn't you say that my lowclass implementation of protected proves that a JavaScript engine could implement it without the problem you pointed out? |
Something just occurred to me, and I feel kind of stupid for not thinking of it sooner. The first issue @littledan listed here, and that @ljharb has had a field day throwing at me every time I mention how much people are going to ask for "protected" support, can be solved just by giving protected members a different reified private name in each subclass. Isn't that the main reason why the example in the OP isn't immediately disagreeable? If a "protected" declaration translated the field into a field accessor (don't ask me how that would work) that bound itself to the field of the base with the same non-reified name, leaking of the type described would be impossible and the OP would have a simple, non-convoluted means of shared access. |
Do you mean something like this but with specific syntax instead of a decorator? import { Protected, Inherit } from "./protected";
class Base {
@Protected #foo = 2;
setFoo(x) { this.#foo = x; }
}
class Child extends Base {
@Inherit #foo;
getFoo() { return this.#foo }
} var privateNames = new WeakMap();
/* This weakmap will be like this:
WeakMap {
Base => Map { "foo" => Base's #foo }
}
*/
export function Protected(desc) {
const { key } = desc;
desc.extras = [{
kind: "hook",
placement: "static",
finish(Class) {
if (!privateNames.has(Class)) privateNames.set(Class, new Map);
privateNames.get(Class).set(key.description(), key);
}
}];
}
export function Inherit(desc) {
const { key, placement, kind } = desc;
return {
key, placement, kind,
get() {
return getSuperPN(this, key).get(this);
},
set(v) {
getSuperPN(this, key).set(this, v);
}
};
}
function getSuperPN(key, instance) {
return privateNames.get(instance.__proto__.constructor).get(key.description());
} |
@nicolo-ribaudo Something like that, yes. Done something like that, each subclass would still never have access to the private names embedded in the parent class, but nobody want's those anyway. The all-important data would simply be accessible via what is essentially a different field name in the subclasses. |
Given that everyone can import that “./protected” module, I’d still consider that fully public, ftr. |
@ljharb Did you miss that this example is just a decorators version of an in-engine implementation? |
I think you are ignoring decades of OOP ability to extend classes with protected properties ... protected means by class definition/extend/declaration, so that nobody can access, through the public instance reference, that property ... but I kinda believe you know this already, right? |
Since this thread seems to have turned into something else, which I don't have time to keep track of, I'll close it. Thanks TC39 for all the discussion; we'll be going with a design similar to the OP, which you can see in whatwg/html#4324. We're hopeful that if decorators advance we can upgrade to a solution based on them at that time. |
@domenic In the OP, how does a sub class of @devsnek I solved the problem you pointed out in lowclass (that by modifying an object's prototype you were able to trick it to getting a protected member from public scope) by making a simple helper called I think An engine would be able to prevent protected access without having to lock a prototype though, though if a prototype is modified, it'd be strange for And plus @rdking found a way to implement true protected (without modifying prototype configurability) in javascript (hopefully there's no flaws that haven't been found yet). We know it is possible for the engine though (lexical scope checking is easy), so I simply believe it would be nice to add syntax space for protected, not just for private, even if the protected feature is not added yet. |
Hi TC39, apologies if this is not the right venue, but I wasn't sure if there was a better one.
For web components APIs, we need to define something very similar to classical "protected state". Concretely, we want to give abilities to manipulate
HTMLElement
's internal state to anyone who derives fromHTMLElement
, while hiding it from arbitrary consumers ofHTMLElement
instances.The original thread is at WICG/webcomponents#758. We found several potential patterns, listed at WICG/webcomponents#758 (comment). The one we've tentatively settled on is the following:
Notice the
needsInternals
check in particular. The idea is that any correct subclass that wants to use the internals will setneedsInternals
to true, and then callthis.getInternals()
in the constructor.Thus, the only way the internals (i.e., the protected state accessors) could leak is if some subclass declares
needsInternals = true
, but runs arbitrary class-consumer code before it callsthis.getInternals()
. Because then the class-consumer code could itself callinstance.getInternals()
, and start messing with the protected state. So, subclasses should not do that; if they do, they have lost encapsulation abilities for their instances.So. That's one pattern. But are there better ones that TC39 would advise? This is the first time the need for such a thing has come up on the web or in the JS spec, but it may set a precedent going forward. We'd love your thoughts before we commit to building these APIs on the web.
The text was updated successfully, but these errors were encountered: