Translation, original article: “Class-field-proposal” or “What Went wrong in TC39 Committee”

We have long looked forward to the day when it will be relatively easy to use the encapsulation syntax common in other languages in JavaScript. For example, we want a syntax for class attributes/fields that is implemented in a way that doesn’t break existing programs. Now it looks like that day has arrived: thanks to the efforts of the TC39 committee, the class field proposal has moved to Stage 3 and has even been implemented by Chrome

Honestly, I’d love to write an article describing why you must use this new feature and how to implement it. But unfortunately I can’t do that.

Description of current Proposal

Reference documents are omitted here, for specific reference: original notes, FAQ, specification changes.

Class fields

Class fields and usage:

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

Access fields from external code:

const a = new A();
console.log(a.x);
Copy the code

At first glance, some might say we’ve been using this in Babel and TypeScript for years.

But one thing is worth noting: this syntax uses [[Define]] semantics instead of the [[Set]] semantics we’re used to. This means that in practice the above code is not equivalent to the following usage:

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

Equivalent to the following usage:

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

Although there is virtually no difference between the two uses in this example, there is an important difference. Let’s assume we have a parent class that looks like this:

class A {
    x = 1;

    method() {
        console.log(this.x); }}Copy the code

A subclass is derived from the parent class as follows:

class B extends A {
    x = 2;
}
Copy the code

Then use:

const b = new B();
b.method(); // prints 2 to the console
Copy the code

Then, for some (unimportant) reason, we change class A in A way that seems backward compatible:

class A {
    _x = 1; // for simplicity let's skip that public interface got new property here
    get x() { return this._x; };
    set x(val) { return this._x = val; };

    method() {
        console.log(this._x); }}Copy the code

It is indeed backward compatible with the [[Set]] semantics. But not for [[Define]]. Calling b.thod () now prints 1 instead of 2 to the console. The reason is that with Object.defineProperty, the property descriptor declared by class A and its getter/setter are not called. Therefore, in derived classes, we hide the parent class x-ness in a way similar to the lexical scope of variables:

const x = 1;
{
    const x = 2;
}
Copy the code

We can use liner rules like no-shadowed-variable/no-shadow to help detect common lexical scope variable hiding. Unfortunately, it is unlikely that anyone would create a no-shadowed-class-field rule to help us avoid hiding class fields.

However, I am not a firm opponent of the [[Define]] semantics (although I prefer the [[Set]] semantics) because it has its good points. However, its advantages don’t outweigh its main disadvantages — we’ve been using [[Set]] semantics for years because it’s the default behavior of Babel6 and TypeScript.

I have to stress that Babel7 changes the default behavior.

You can read more about the original discussion here and here.

Private field

Let’s look at the most controversial part of the proposal. It’s so controversial:

  1. Despite the fact that it’s already implemented in Chrome Canary and public fields are available by default, private fields need to be turned on extra;
  2. Despite the fact that the original private field proposal was merged with the current proposal, the issue of separating private and public fields came up again and again (e.g. 140,142,144,148);
  3. Even some committee members (e.g.Allen Wirfs-BrockandKevin Smith) also oppose it and offeralternativeBut the bill went aheadstage 3;
  4. This proposal has the largest number of issues — the current proposal has 131 GitHub repositories compared to 96 for the original proposal (before the merger) (compared to 126 for BigInt’s proposal), and most of the issues have opposing views;
  5. A separate issue was even created to tally up objections to it;
  6. A separate FAQ was created to justify this part, but the lack of strong arguments led to a new debate (133,136)
  7. Personally, I spend almost all of my free time (and sometimes even my work time) trying to investigate it, fully understand the limitations and decisions behind it, understand why it is what it is, and propose viable alternatives;
  8. Finally, I decided to write this review.

Syntax for declaring private fields:

class A {
    #priv;
}
Copy the code

And access it using the following notation:

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

This syntax seems counterintuitive and not intuitive (this.#priv! = this[‘#priv’]), and doesn’t use JavaScript’s reserved privaye/protected word (which can be annoying for developers already using TypeScript), and leaves open the possibility of more access-level design. Under such circumstances, I conducted in-depth investigations and participated in relevant discussions.

If this is only about grammatical form, which is subjectively and aesthetically unacceptable to us, then eventually we might be able to live with such grammar and get used to it. But there is also a semantic problem…

WeakMap semantic

Let’s look at the semantics behind the existing proposal. We can rewrite the previous example without the new syntax but with the same behavior:

const privatesForA = new WeakMap(a);class A {
    constructor() {
        privatesForA.set(this{}); privatesForA.get(this).priv = 1;
    }

    method() {
        console.log(privatesForA.get(this).priv); }}Copy the code

Incidentally, a committee member created a small utility library using this semantics, which allows us to use private state now. His goal was to show that the functionality was overrated by the commission. The formatting code is only 27 lines long.

Great, we can now have hard private so that internal fields cannot be accessed/intercepted/traced from external code, and we can even access the private of another instance of the same class by:

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

This is all very handy, except that the semantics include not only encapsulation but brand-checking as well (you don’t have to Google the term, because you’re unlikely to find any relevant information). Brand-checking is the opposite of the duck type, in the sense that it determines a particular object based on a particular code rather than its public interface. In fact, such checks have their own uses — in most cases, they relate to safely executing untrusted code in the same process, sharing objects directly without serialization/deserialization overhead.

But some engineers insist that this is a requirement for proper packaging.

Although this is a very interesting possibility, involving membrane patterns (short and detailed), Realms proposals, and computer science research by Mark Samuel Miller (who also sits on the committee), in my experience it doesn’t seem to be common to most developers’ daily work.

brand-checkingThe problem of

As I said earlier, brand-checking is the opposite of the duck type. In practice, this means using the following code:

const brands = new WeakMap(a);class A {
    constructor() {
        brands.set(this{}); } method() {return 1;
    }

    brandCheckedMethod() {
        if(! brands.has(this)) throw 'Brand-check failed';

        console.log(this.method()); }}Copy the code

The brandCheckedMethod can only be called by instances of A, and this method throws an exception even if target matches all the constructs of this class:

const duckTypedObj = {
    method: A.prototype.method.bind(duckTypedObj),
    brandCheckedMethod: A.prototype.brandCheckedMethod.bind(duckTypedObj),
};
duckTypedObj.method(); // no exception here and method returns 1
duckTypedObj.brandCheckedMethod(); // throws an exception
Copy the code

Clearly, this example is deliberate, and the benefits of this duck type are questionable. Unless we’re talking about proxies. There is one very important use scenario for agents – metaprogramming. In order for the proxy to perform all necessary useful work, the methods of the proxy-wrapped object should be called in the context of the proxy, not in the target:

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)// actually bind here is redundant, but I want to focus your attention on method's context: property; }});Copy the code

Call proxy.method (); Will result in doing some useful work declared in the proxy and returning 1 when proxy.brandCheckedMethod() is called; Instead of doing something useful twice will cause an exception to be thrown because a! == proxy and brand-check failed.

Of course, we can implement methods/functions in the context of real targets rather than agents, and it is sufficient in some cases (such as implementing the membrane pattern), but it is not sufficient in all cases (for example, implementing the reaction properties: MobX 5 already uses proxy implementation, vue.js and Aurelia are experimenting with this approach for future versions).

Typically, although brand-Check requires explicit declarations, this is not a problem: the developer simply chooses which trade-offs he/she needs and why. In the case of explicit brand-check, it can be implemented in a way that allows it to interact with some trusted agent.

Unfortunately, the current proposal does not give this flexibility:

class A {
    #priv;

    method() {
        this.#priv; // brand-check ALWAYS happens here
    }
}
Copy the code

The method method will always throw an exception if called in the context of an object not built by A’s constructor. This is the scariest truth of all: brand-check is implicit here and mixed with another feature, encapsulation.

While almost all types of code require encapsulation, the number of use cases for brand checking is very limited. When developers want to hide implementation details, mixing them into one syntax leads to unexpected brand-check, and to promote the proposal, advertising # is new _ makes it worse.

You can also read the details of the discussion about the current proposal breaking agency behavior. Aurelia developers and vue.js authors are involved. In addition, my comments describe the differences between several use cases for the broker, which could be interesting. The relationship between private field and membrane mode is also discussed.

An alternative to

All this discussion does not amount to much unless there is an alternative. Unfortunately, none of them reached the first stage, so these alternative proposals did not have an opportunity to be fully developed. However, I would like to point out some of them to address the above problems in some way.

  1. Symbol.private– An alternative proposal from one of the committee members.
    1. Solve all the problems described above (it may have its own problems, but it is hard to find without further development)
    2. The committeeRecent meetingsOn was rejected again due to lack of built-inbrand-check.membraneSchema issues (buthereandhereOffers workable solutions) and a lack of convenient syntax
    3. Convenient syntax can be built on this proposal, as shown here and here
  2. Classes 1.1 – An earlier proposal from the same author
  3. theprivateUse as an object

conclusion

It may seem as if I blame the committee — but in fact, I don’t. I just think that there have been years (or even decades, depending on where you start) of effort to achieve proper encapsulation in JS, and a lot of changes have taken place in our industry, and perhaps the commission has missed some of those changes. As a result, the priorities of the relevant bodies may become somewhat blurred.

Not only that, but we, as a community, pushed TC39 to release features faster, but we didn’t provide enough feedback on early proposals, resulting in a lot of debate and little time to change things.

It is argued that in this case the proposal process failed.

After diving for so long, I decided to do everything I could to prevent this from happening in the future. Unfortunately, THERE wasn’t much I could do — just write reviews and implement early proposals in Babel.

Overall, feedback and communication are the most important, so I urge you to share more ideas with the committee.

Translation reference

  • Docs.microsoft.com/zh-cn/dotne…