“Class-fields-proposal” or “what went wrong in tc39 committee”

Igor Chulinda
7 min readNov 6, 2018

--

All of us want normal encapsulation in JS, that could be used without additional gymnastics, for ages. Also we want usable syntax for class properties/fields. And we want this features implemented in way which won’t break any existing applications. And it seems that we already reached this happy day: after years of tc39’s hard work class-fields-proposal is in stage 3 and even has implementation in Chrome.

Honestly, I would love to write an article which describes why do you have to use this new feature and how you can achieve it. Unfortunately, this article is about something else.

Current proposal description

I won’t repeat the original description, FAQ and specification changes here, but instead will focus on themost important things.

Class fields

Fields declaration and usage inside class:

Access fields from outer code:

At first glance it seems familiar, and some of you could say that we use it for years in Babel and TypeScript.

But there is one thing: this syntax uses [[Define]] semantic instead of [[Set]] that we’re used to.
It means that in practice the code above isn't equal to the following:

Instead, it’s equivalent to this one:

And, even though in case of this particular example both approaches act in mostly same way, there is a VERY IMPORTANT difference. And that’s why:
Let’s assume that we have a parent class like this:

Deriving from it, we created following:

And then used it:

After that for some (unimportant) reason we changed class A in a way that seems to be backward-compatible:

For [[Set]] semantic it is indeed a backward-compatible change. However, for [[Define]] it is not. Now calling b.method() will print 1 instead of 2 to the console. This happens, because Object.defineProperty that redeclares property descriptor and getter/setter from class A won't be called. Therefore, in the derived class we shadowed parent's x property in the same way as we may do it in lexical scope:

Luckily, we have linters with no-shadowed-variable/no-shadow rules that will inform us about such shadowing. But it's very unlikely that somebody will create no-shadowed-class-field rule, that will save us from such kind of shoot yourself episodes with classes.

Despite all of the above, I am not an irreconcilable opponent of the [[Define]] semantics (although I would prefer [[Set]]), because it has its own positive aspects. However, unfortunately, its advantages do not outweigh the main disadvantage - we have been using [[Set]] semantic for years, since it's used in babel6 and TypeScript by default.

I have to mention that babel7 changed the default behavior.

You can read more of original discussions here and here.

Private fields

Let’s hove on to the most controversial part of this proposal. It’s such controversial that:

  1. despite the fact that it’s implemented in Chrome Canary and public fields are available by default, privates are still under the flag;
  2. despite the fact, that original private fields proposal was merged with current proposal, issues about separating private and public parts appear again and again (e.g. one, two, three and four);
  3. even some committee members (e.g. Allen Wirfs-Brock and Kevin Smith) speak out against it and offer alternatives, despite stage3 status of the proposal;
  4. this proposal has highest number of issues — 131 in current repo + 96 in initial one, against 126 for BigInt, and in most cases those are negative;
  5. separate thread was created in order to summarize all claims to it;
  6. separate FAQ, was created to justify this part;
    but weak arguments in it lead to new discussions (one, two)
  7. I, personally, was spending all my free time (and sometimes even work time) for quite a long period trying to investigate it, get full understanding of limitations and decisions behind it, find explanation why it looks like this and propose viable alternative;
  8. at last, I decided to write this review article.

Private fields are declared using such syntax:

And accessed using following notation:

I won’t even focus on mental model issues behind this syntax like that it’s unintuitive (this.#priv !== this['#priv']), doesn't use reserved private/protected keyword (which will lead to additional headache for TypeScript developers), it's unclear how to extend it for other access modifiers, and looks not very familiar. Even though it was my initial reasoning for deeper investigation and participation in discussions.

All this stuff is related to syntax only, which means that subjective aesthetic opinion comes in play. In the end we could live with such syntax and get used to it. But there’s one but: there is a semantic problem too…

WeakMap semantic

Let’s take a look at semantic behind existing proposal. We are able to rewrite previous example without new syntax but keeping behavior:

By the way, one of committee members created a small utility library using this semantic, which allows us to use private state right now. His goal was to show that such functionality is overvalued by committee. Formated code has only 27 lines.

It’s very good — we could have hard-private , which can't be accessed/intercepted/tracked from outer code, and at the same time we're even able to access privates of another instance of same class in following way:

It is all very convenient, except that this semantic includes not only encapsulation but also brand-checking (you don’t have to google this term, since it's unlikely that you'll find any relevant information).
brand-checking is opposite to duck-typing, in sense that it checks the fact that particular object was constructed by particular code and not the public interface of that object.
Such check, actually, has its own uses - in most cases they relate to secure execution of untrusted code in same process with possibility to share objects directly without serializing/deserializing overhead.

But some engineers insist that it’s a requirement for proper encapsulation.

Despite the fact that it’s very interesting possibility, which relates to Membrane pattern (short and long description), Realms proposal and scientific researches in Computer Science of Mark Samuel Miller (he is a committee member too), it doesn't seem to commonly appear in day-to-day work of majority of developers in my experience.

By the way, I met Membrane pattern (but I didn't know it by name), when forking vm2 to rewrite it according to my needs.

brand-checking problem

As I said before, brand-checking is the opposite to duck-typing. In practice it means that using the following code:

brandCheckedMethod could be called only with instance of A and even if target conforms all invariants of this class, this method will throw an exception:

Obviously, this sample is very syntethic and benefits of such duckTypedObj are doubtful. Unless we talk about Proxy.
There is very important usage scenario of proxies - metaprogramming. In order to make proxy do all required useful work, methods of objects wrapped by proxy should be called in proxy's context and not in target's:

Calling proxy.method(); will lead to doing some useful work declared in proxy and returning 1, when calling proxy.brandCheckedMethod(); instead of doing some useful work twice will lead to throwing an exception, because a !== proxy and brand-check failed.

Surely, we can execute methods/functions in context of real target instead of proxy, and for some scenarios it is enough (e.g. to implement Membrane pattern), but it's not enough for all cases (e.g. to implement reactive properties: MobX 5 already uses proxy for this, Vue.js and Aurelia are experimenting with this approach for future releases).

Generally, while brand-check should be declared explicitly, it's not a problem: the developer just has to choose which trade-off he/she needs and why. Even more in case of explicit brand-check it could be implemented in way that allows interactions with some trusted proxies.

Unfortunately, current proposal doesn’t grant such flexibility:

Such method will throw an exception always, if called in context of an object that doesn't built with constructor of A. The most terrible thing here is the fact that brand-check is implicit here and mixed with another feature - encapsulation.

While encapsulation is required for almost all types of code, brand-check has very limited number of use cases. Mixing them into one syntax will lead to appearing of dozens of unintended brand-checks, when developer just wanted to hide implementation details.
And the slogan, which is used for promoting this proposal, # is the new _ only aggravates the situation.

Also you can read detailed discussion about breaking proxies by current proposal. On of Aurelia developers and Vue.js author participated in it.

Also my comment, which describes difference between several use cases for proxies, could be interesting. And whole discussion about relations between privates and Membrane pattern too.

Alternatives

All these discussions wouldn’t have much sense, unless there are alternatives. Unfortunately, none of them reached even stage1, so such alternative proposals have no chances to become sufficiently developed. However, I want to point out a few of them, that solve problems described above one way or another.

  1. Symbol.private — alternative proposal from one of the committee members.
    1. Solves all problems described above (it could have its own issues, but without further developing it’s hard to find them)
    2. was rejected once again at latest committee meeting because of lack of built-in brand-check, Membrane pattern issues (but this + this provide viable solution) and lack of convenient syntax
    3. convenient syntax could be built on top of this proposal, as shown here and here by me
  2. Classes 1.1 — older proposal from same author
    Class-members — fork of previous one that adds public fields
  3. Using private as an object

In lieu of conclusion

It could seem that I blame the committee — actually, I do not. I just think that after years (or even decades, depending on starting point selected) that were spent on implementing proper encapsulation in JS, a lot of things changed in our industry and probably the committee missed some of these changes. As a result, the priorities might have got mixed.
And even more, we, as a community, force tc39 to release features faster and at the same time we don’t provide enough feedback for early stage proposals, while we argue a lot about when there is not enough time for changing something.
There is an opinion that in this case the process just failed.
After this deep dive, I decided to do my best in order to prevent such situations in future. Unfortunately, I can’t do a lot — only write review articles and implement early stage proposals in babel.
However, feedback is the most important thing, so I beg you share you thoughts with the committee more.

--

--