📜 ⬆️ ⬇️

Class-fields-proposal or What went wrong on the tc39 committer

All of us long ago wanted normal encapsulation in JS, which could be used without unnecessary gestures. We also want convenient constructs for declaring class properties. And, finally, we want all these features in the language to appear so as not to break existing applications.


It would seem that here it is happiness: the class-fields-proposal , which after many years of torment of the tc39 committee did get to stage 3 and even got implemented in chrome .


Honestly, I would really like to write an article just about why it is worth using a new feature of the language and how to do it, but unfortunately, the article will not be about that at all.


Description of current proposal


I will not repeat here the original description , FAQ and changes in the specification , but only briefly outline the main points.


Class fields


Declaring fields and using them inside the class:


 class A { x = 1; method() { console.log(this.x); } } 

Access to non-class fields:


 const a = new A(); console.log(ax); 

It would seem that everything is obvious and we have been using this syntax for many years with the help of Babel and TypeScript .


Only there is a nuance. This new syntax uses the [[Define]] , rather than the [[Set]] semantics with which we have lived all this time.


In practice, this means that the code above does not equal this:


 class A { constructor() { this.x = 1; } method() { console.log(this.x); } } 

And in fact, it is equivalent to this:


 class A { constructor() { Object.defineProperty(this, "x", { configurable: true, enumerable: true, writable: true, value: 1 }); } method() { console.log(this.x); } } 

And, although for the example above, both approaches do, in fact, the same thing, it is a VERY SERIOUS difference, and here is why:


Suppose we have such a parent class:


 class A { x = 1; method() { console.log(this.x); } } 

Based on it, we created another one:


 class B extends A { x = 2; } 

And they used it:


 const b = new B(); b.method(); //   2   

After that, for some reason, class A was changed in a seemingly backward-compatible way:


 class A { _x = 1; //  ,   ,        get x() { return this._x; }; set x(val) { return this._x = val; }; method() { console.log(this._x); } } 

And for [[Set]] semantics, this is indeed a backward compatible change, but not for [[Define]] . Now the b.method() call will display 1 instead of 2 in the console. And this will happen because Object.defineProperty overrides the property dexryptor and, accordingly, the A / heterter from class A will not be called. In fact, in the child class we overshadow the parent's x property, just as we can do it in the lexical scopa:


 const x = 1; { const x = 2; } 

True, in this case the linter with his no-shadowed-variable / no-shadow rules will save us, but the likelihood of someone doing a no-shadowed-class-field tends to zero.


By the way, I will be grateful for a more successful Russian-language term for shadowed .

In spite of all the above, I am not an irreconcilable opponent of the new semantics (although I would prefer a different one), because it has its own positive aspects. But, unfortunately, these advantages do not outweigh the main disadvantage - we have been using [[Set]] semantics for many years, because it is precisely this that is used in babel6 and TypeScript , by default.


True, it is worth noting that in babel7 default value has been changed .

More original discussions on this topic can be found here and here .


Private fields


And now we come to the most controversial part of this proposal. So controversial that:


  1. despite the fact that it is already implemented in Chrome Canary and public fields are already enabled by default, private ones are still flagged;
  2. in spite of the fact that the initial propozal for private fields was merged with the current one, requests are still being created for the separation of these two features (for example, one , two , three and four );
  3. even some committee members (for example, Allen Wirfs-Brock and Kevin Smith ) oppose and offer alternatives , despite stage3 ;
  4. This propozal set a record for the number of issues - 129 in the current repository + 96 in the original , against 126 for BigInt , with what the record holder has mostly negative comments ;
  5. I had to create a separate thread with an attempt to somehow summarize all the claims against it;
  6. I had to write a separate FAQ , which reproves this part
    however, due to a rather weak argument, such discussions also appeared ( one , two )
  7. I, personally, spent all my free time (and sometimes working time) for a long period of time, in order to sort things out and even find an explanation why he is like that or offer a suitable alternative ;
  8. I finally decided to write this review article.

Private fields are declared as follows:


 class A { #priv; } 

And access to them is as follows:


 class A { #priv = 1; method() { console.log(this.#priv); } } 

I will not even raise the topic of the fact that the mental model behind this is not very intuitive ( this.#priv !== this['#priv'] ) does not use the already reserved words private / protected (which will surely cause additional pain for TypeScript developers), it is not clear how to extend this for other access modifiers , and the syntax itself is not very beautiful. Although all this was the original reason that pushed me to a deeper study and participation in discussions.


This all concerns the syntax where subjective esthetic preferences are very strong. And with this one could live and get used to it over time. If it were not for one thing: there is a very significant problem of semantics ...


WeakMap


Let's take a look at what is behind the existing prosobal. We can rewrite the example from above with encapsulation and without using the new syntax, but keeping the semantics of the current proposal:


 const privatesForA = new WeakMap(); class A { constructor() { privatesForA.set(this, {}); privatesForA.get(this).priv = 1; } method() { console.log(privatesForA.get(this).priv); } } 

By the way, on the basis of this semantics, one of the committee members even built a small utility library , which allows using the private state now, in order to show that such functionality is too overestimated by the committee. The formatted code takes only 27 lines.

In general, everything is pretty good, we get hard-private , which can not be reached / intercepted / tracked from the external code and we can get access to the private fields of another instance of the same class, for example:


 isEquals(obj) { return privatesForA.get(this).id === privatesForA.get(obj).id; } 

Well, it is very convenient, except for the fact that this semantics, in addition to encapsulation itself, also includes brand-checking (you can not google what it is - you can hardly find relevant information).
brand-checking is the opposite of duck-typing , in the sense that it does not check the public intefrace of the object, but the fact that the object was built using trusted code.
Such a check, in fact, has a certain scope - it is mainly related to the security of calling untrusted code in a single address space with a trusted and ability to exchange objects directly without serialization.


Although some engineers consider this to be a necessary part of proper encapsulation.

Despite the fact that this is a rather curious opportunity, which is closely related to the pattern ( short and longer description), Realms proposal and scientific work in the field of Computer Science, which Mark Samuel Miller (he is also a member of the committee), in my experience , in the practice of most developers, this almost never occurs.


By the way, I came across a membrane (although I didn’t know what it was then) when I rewrote vm2 to fit my needs.

Problem brand-checking


As mentioned earlier, brand-checking is the opposite of duck-typing . In practice, this means that having such code:


 const brands = new WeakMap(); class A { constructor() { brands.set(this, {}); } method() { return 1; } brandCheckedMethod() { if (!brands.has(this)) throw 'Brand-check failed'; console.log(this.method()); } } 

brandCheckedMethod can only be called with an instance of class A and even if an object that preserves invariants of this class acts as a target, this method will throw an exception:


 const duckTypedObj = { method: A.prototype.method.bind(duckTypedObj), brandCheckedMethod: A.prototype.brandCheckedMethod.bind(duckTypedObj), }; duckTypedObj.method(); //        1 duckTypedObj.brandCheckedMethod(); //      

Obviously, this example is rather synthetic and the benefits of such duckTypedObj questionable, as long as we don’t remember Proxy .
One of the very important use cases for proxies is metaprogramming. In order for the proxy to perform all the necessary useful work, the methods of objects that are wrapped with a proxy should be executed in the context of the proxy, and not in the context of the target, ie:


 const a = new A(); const proxy = new Proxy(a, { get(target, p, receiver) { const property = Reflect.get(target, p, receiver); doSomethingUseful('get', retval, target, p, receiver); return (typeof property === 'function') ? property.bind(proxy) : property; } }); 

Call proxy.method(); does the useful work declared in the proxy and returns 1 , while calling proxy.brandCheckedMethod(); instead of twice doing a useful job from a proxy, throw an exception, because a !== proxy , which means brand-check failed.


Yes, we can perform methods / functions in the context of the real target, and not a proxy, and for some scenarios this is enough (for example, to implement the pattern), but this is not enough for all cases (for example, to implement reactive properties: MobX 5 already uses a proxy for this, Vue.js and Aurelia experiment with this approach for future releases).


In general, as long as a brand-check needs to be done explicitly, this is not a problem - the developer simply consciously has to decide what trade-off he is doing and whether he needs it, moreover, in the case of an explicit brand-check you can implement it in such a way that the error would not be thrown on trusted proxies.


Unfortunately, the current proposal deprives us of this flexibility:


 class A { #priv; method() { this.#priv; //    brand-check   } } 

Such a method will always throw an exception if it is called not in the context of an object constructed using the constructor A And the worst thing is that brand-check is implicit here and is mixed with another functionality - encapsulation.


While almost necessary for any code, brand-check has a fairly narrow range of applications. And combining them into one syntax will result in a lot of unintentional brand-check in the user code, when the developer intended only to hide implementation details.
And the slogan, which is used to promote this propozala # is the new _ only aggravates the situation.


You can also read a detailed discussion of how an existing propozal breaks a proxy . One of the Aurelia developers and the author Vue.js spoke in the discussion .

Also, my commentary , describing in more detail the difference between different proxy usage scenarios, may seem interesting to someone. As well as in general, all discussion of the relationship of private fields and membranes .

Alternatives


All these discussions would make little sense if there were no alternatives. Unfortunately, no alternative proposal even got into stage1 , and, as a result, neither had the chance to be sufficiently developed. However, I will list here the alternatives that somehow solve the problems described above.


  1. Symbol.private - an alternative proposal of one of the committee members.
    1. Solves all the above problems (although it may have its own, but, in the absence of active work on it, it is difficult to find them)
    2. it was once again thrown back at the last meeting of the committee due to the lack of a built-in brand-check , problems with the membrane pattern (although this also offers an adequate solution) and the lack of a convenient syntax
    3. convenient syntax can be built on top of the prozal itself, as shown by me here and here
  2. Classes 1.1 - earlier propozal from the same author
  3. Using private as an object

Instead of conclusion


From the tone of the article, perhaps it may seem that I condemn the committee - this is not so. It only seems to me that during those years (depending on what to take as a starting point, it could be even decades), which the committee worked on encapsulation in JS, a lot of things in the industry changed, and the look could be blurred, which led to a false prioritization .


Moreover, we, as a community, are putting pressure on tc39 forcing them to release features faster, while giving very little feedback in the early stages of propozal, bringing down their indignation only at the moment when there is little that can be changed.


There is an opinion that in this case the process simply failed.


After dipping into this with my head and communicating with some representatives, I decided that I would do my best to prevent a repetition of this situation - but I can do a little (write a review article, do the implementation stage1 in babel and just that).


But the most important thing is the feedback - so I would ask you to take part in this short survey. And I, in turn, will try to bring it to the committee.


')

Source: https://habr.com/ru/post/428119/


All Articles