Skip to content
This repository has been archived by the owner on Jan 25, 2022. It is now read-only.

Another argument on why this might not be a good idea #272

Closed
fabiosantoscode opened this issue Sep 24, 2019 · 68 comments
Closed

Another argument on why this might not be a good idea #272

fabiosantoscode opened this issue Sep 24, 2019 · 68 comments

Comments

@fabiosantoscode
Copy link

I've been implementing this proposal within Terser, to make sure people can minify their modern code. However I'm haunted by the complexity of it (when trying to implement it inside of a minifier that does the same basic assumptions of ES all over).

This proposal, which provides some neat syntax sugar, has a very bad readability problem to me and probably newcomers to ES.

The problem lies with execution order and execution count. Consider this example:

class X {
  static prop = doSomething();
  prop2 = doSomethingElse();
}

It's obvious that doSomething is called immediately, however doSomethingElse is called zero, one or many times, depending on how many times the class is constructed.

This breaks the basic assumption that code that's not inside of a switch statement has some pretty linear ordering guarantees. That something inside of a pair of braces is guaranteed to be executed in sequence, not at all, or many times. Not that parts of what is in a pair of braces are executed once, and other parts are executed however many times something happens.

This is very unreadable to me, and even though I've enjoyed the sweet sugar using Babel I must denounce this as something that makes ES harder to read and understand.

Thoughts?

@ljharb
Copy link
Member

ljharb commented Sep 24, 2019

code inside functions already has no such guarantees; I’m not sure why this line is different.

@fabiosantoscode
Copy link
Author

It does until you see something with a pair of braces. Take this example:

function x() {                       
    if (something) {                 
        // executed 0 or 1 time      
    }                                
    for (const x of somethingElse) { 
        // executed 0, 1 or n times  
    }                                
}                                    

If there's not a pair of braces or function inside, there's no way something will happen later, more than once or not at all.

@ljharb
Copy link
Member

ljharb commented Sep 24, 2019

const x = false && willNeverHappen();
await willHappenLater();
await Math.random() > 0.5 ? willMaybeHappenLater() : willMaybeHappenNow();
while(true) noBracesHappensManyTimes();

@nicolo-ribaudo
Copy link
Member

@fabiosantoscode Could a minifier first move all the static declarations to the top, so that then the order is linear?

class A {
  [bar()] = 1;
  static [foo()] = 2;
  x = 3;
  static y = 4;
} 

->

const key1 = bar(), key2 = foo();
class A {
  static [key2] = 2;
  static y = 4;

  [key1] = 1;
  x = 3;
}

@rdking
Copy link

rdking commented Sep 24, 2019

@ljharb

//Well known short-circuit evaluation caused by operator &&
const x = false && willNeverHappen();
//Does indeed happen later, but you're waiting on it, so it's effectively happening now.
await willHappenLater();
//Implied braces as `x?y:z` === `if(x){y}else{z}`
await Math.random() > 0.5 ? willMaybeHappenLater() : willMaybeHappenNow();
//Single line statement fully equivalent to `while(true){noBracesHappensManyTimes();}
while(true) noBracesHappensManyTimes();

The point is that in each of your examples, it is either a 1-line concession that allows braces to be omitted, or an operation with implied braces due to a keyword or operator. The same thing cannot be seen in the more comparative lexical declarations. That's the point. It's rather peculiar to have code with a completely indeterminate execution sequence in line inside a declaration.

@nicolo-ribaudo
That would clean up the grouping, but it would do nothing for the understanding about the execution time. That seems to be the core of the OP's question. There's nothing that implies at a glance that public fields are only initialized at construction. So only those who have been made aware of this will understand the real timing behind what they're looking at. Everyone else runs the risk of assuming that all such assignments happen together.

@ljharb
Copy link
Member

ljharb commented Sep 25, 2019

@rdking saying "well-known" is a bit misleading; class fields will become well-known in time too - otherwise nothing new would ever be added because only old stuff is "well-known".

Note that this also works:

while
(true)

/*
  some long comment
*/

noBracesHappensManyTimes();

so it's got nothing to do with "1-line". Certainly the braces are implied - in class fields, the "delayed execution" is also implied.

@a-ejs
Copy link

a-ejs commented Sep 25, 2019

@ljharb No line in your example will be executed before a line above it. All statements will be evaluated in order.

@ljharb
Copy link
Member

ljharb commented Sep 25, 2019

@a-ejs certainly. but since class instance fields have different semantics, and a class body is not a statement list like normal code, an expectation that it works like normal code is incorrect.

Whether it's intuitive or not is subjective, of course.

@littledan
Copy link
Member

Of all of the aspects of this proposal, I'm very confident that this one will be fine in practice, because we have very wide experience with this syntax in Babel and TypeScript over the past few years, and have seen that developers are happy with it in general.

@fabiosantoscode
Copy link
Author

It might be anecdotal, but for me personally, I wrote code like this:

// contrived example
class SomeComponent {
  constructor() {
    this.state = {
      someState: { someMoreState: 1234 }
    }
  }
}

Instead of using class fields in React with Babel, thinking that if I added the class field the state = [object] would only be evaluated once, and therefore every object inside wasn't safe to be mutated without affecting other instances of the component.

It was my intuition that the class field would only be evaluated once and used multiple times, even though I've been using this syntax since 2016 (or earlier, I cannot recall).

Another problematic piece of code in terms of readability because of this issue is:

// warning: there shall be multiple symbols here!
class Foo {
  somethingUniqueToThisClass = Symbol('i am unique')
}

@fabiosantoscode
Copy link
Author

fabiosantoscode commented Sep 25, 2019

While it might be true that in the case of while (something) foo(), foo() is evaluated multiple times and no braces are involved, it's very surprising that something merely involving an = sign is evaluated multiple times, where other cases of equal signs are assignments and variable creation and not any kind of flow control or function creation (the obvious things that change execution order and count).

It might be intuitive for people who write things like Java, where this syntax feels OK, but for me coming from python this doesn't make much sense.

Here's me creating a Foo class and instantiating it a couple of times. Watch when the property gets evaluated.

>>> class Foo(object):
...     something = print("I got evaluated!")
... 
I got evaluated!
>>> Foo()
<__main__.Foo object at 0x7f2a150f15d0>
>>> Foo()
<__main__.Foo object at 0x7f2a150f1650>

@fabiosantoscode
Copy link
Author

And here's some ruby too.

irb(main):001:0> class Foo
irb(main):002:1>   @something = puts "I got evaluated!"
irb(main):003:1> end
I got evaluated!
=> nil

@jridgewell
Copy link
Member

but for me coming from python this doesn't make much sense.

Python also has a bad implementation of default parameters. JS reevaluates the parameter every time. Python's precedence is absolutely not what we want to use.

And here's some ruby too.

This example is using a class instance variable, which is like a static field in JS. Ruby doesn't have a declarative way to define instance variables, it's done imperatively during construction.

@fabiosantoscode
Copy link
Author

fabiosantoscode commented Sep 25, 2019

Oh, my bad.

But there's probably a good reason ruby doesn't allow for class fields. I would say it's the same issue I'm talking about

Not implying that it's a programming language or anything, but PHP, while it allows for these class fields, greatly restricts the feature, only allowing for numbers, strings, arrays and little more.

@a-ejs
Copy link

a-ejs commented Sep 26, 2019

@ljharb

not a statement list like normal code

with this new proposal, not anymore it isn't. But currently 'function code' can only be found in functions, that's a very reasonable expectation to have in a language like JS, and to me breaking this rule sounds like a big deal. (and that's just one issue this proposal has)

@fabiosantoscode
Copy link
Author

But currently 'function code' can only be found in functions

Thanks for expressing my concern better than me :)

@rdking
Copy link

rdking commented Sep 27, 2019

@ljharb

saying "well-known" is a bit misleading; class fields will become well-known in time too - otherwise nothing new would ever be added because only old stuff is "well-known".

You're right, if we get stuck with class-fields, it will eventually be come well known that certain current day programming paradigms will be useless in the face of "fields". However, that doesn't make anything I've said misleading. The prototype problem has existed in this language since the beginning and has (at least to my knowledge) been a well-known issue since ES3. This means that even noobs get introduced to it fairly quickly one way or another due to proliferation of existing best practices.

The simple fact is that the out-of-sync execution of "fields" is going to prove problematic for anyone not familiar with the concept simply because it is not intuitive. It doesn't fit the general workflow of ES. It doesn't fit the general usage patterns of ES. It doesn't even fit the general structure of ES. It's a completely foreign concept to the language. When the wider audience becomes aware of it (and after moving to NC, I'm aware of just how unaware most ES programmers are about this), there will be issues until sufficient time has passed for the community to adjust to the loss of existing good practices and adopt new ones.

@ljharb
Copy link
Member

ljharb commented Sep 27, 2019

As far as i know, it’s been intuitive to everyone who’s used the feature via babel for the last 4+ years, including much of the react community - which i suspect is more syntactic evidence of intuitiveness than almost any other language feature has had.

If there’s been any feedback over the years from that large community that this has been confusing, I’d be very interested to see it.

@rdking
Copy link

rdking commented Sep 27, 2019

Over the years behind this proposal, many of us have tried to show you that confusion by introducing large unaware populations to this proposal, and then polling them after they've learned it. However, when those polls were brought to light in the various threads, TC39 (yourself included) dismissed them with arguments about the invalidity of design by polling/popularity/etc. Then the very next thing would be an evidence-less comment much like the one you just made.

It's not that I think you're being disingenuous, but rather quite a bit too general. Really? "It's been intuitive to everyone" without exception? I've used babel many times over the last 4 years. I've also used React. And the 1 thing I can say is that both of them, where fields are concerned, used [[Set]] instead of [[Define]] at the time and avoided several of the painful issues regarding "fields". Even with that, I still found myself perplexed that:

  1. the "fields" are not on the prototype object at all.
  2. the "field" initializers are not single resolution.

For all my experience with ES, that was completely outside my expectations. It almost made sense when I thought about it in terms of how Java classes work, except that with Java classes, each class in the inheritance chain has its own storage space within the instance object. With the way ES handles class, that was not done. So what little chance "fields" had of making any sense at all in my eyes has been lost. I'm not the only one who feels this way.

Long story short, class-fields is attempting to apply a concept to ES6 class that doesn't fit with the model underlying prototype-based classes. I'm not saying the solution doesn't work. I'm saying the mental model is inconsistent and unintuitive. Just as TC39 wants to imply that private x is always associated with this.x, I'm saying that the "fields" concept is likewise tied to per class, hierarchal instance data storage.

So.... please downgrade your absolute "everyone" to a more reasonable, indefinite "many".

@ljharb
Copy link
Member

ljharb commented Sep 27, 2019

Fair enough.

@pzuraq
Copy link

pzuraq commented Oct 5, 2019

So, I just wanted to comment on here because it seems like this discussion keeps going, with some very vocal critics and maybe not many testimonials from actual class fields users. I've read through a lot of the criticisms of this proposal, and I understand them pretty well at this point I think. In isolation, I think there are two good points:

  1. Private syntax is a bit confusing and strange.
  2. Field assignments are a bit strange, since they run once per instance and work very differently than property assignments.

However, when considering the full design process, I believe that the decisions for the behaviors in this spec make a lot of sense, and are (counter to the claims of the critics) very much informed by real world usage and experience. I think the core of all of these disagreements comes from this statement on #150:

We don't need public property/field declarations and if we must have them, they should follow existing property semantics instead of fields. Fields cause pernicious bugs when overriding properties in subclasses or refactoring base classes.

This is one of the main starting points for many of the final decisions in this proposal, IMO. If public fields aren't necessary, as #150 claims, then the design space does open back up. Ideas like those proposed in Classes 1.1 and #264 become possible, and even make some sense.

However, if public properties/fields are required, then the private syntax that is proposed really does become the least-bad option, as it makes a lot of sense to make sure that public and private fields behave similarly and work together. Indeed, this was the whole reason why public class fields were not shipped on their own, in isolation, in the first place.

So, let's examing this claim. Are public fields/properties unecessary? And does their behavior cause pernicious bugs?

Are public fields unecessary?

A lot of the counter arguments center on the idea that since fields are syntactic sugar for execution in the constructor, that should be the norm for declaring them. There isn't a need to have a dedicated syntax just for declaring properties on the instance. However, I think this undersells some of the benefits of having public property/field declaration:

  1. They are statically analyzable by tooling (editor autocompletes, doc generators, etc). This is a major win when writing code in a day to day setting, as it becomes much, much easier to understand the shape of a class, and to know what values you can set and work with on it.
  2. They are more optimizable due to the fact that they can be statically analyzed. This is a weaker argument IMO, since no amount of performance can make up for terrible DX.
  3. They are decoratable. This has been a major win for declarative meta programming in the language, and really extends the power of the language. Early adopters of decorators have been able to do a lot with them, for example MobX's @observable or Ember.js's @tracked.

These all come down to having a way to specify a class element which is not an accessor or a method, be it public or private. The ability to do this in a declarative way already shows a lot of promise, and I believe between potential future modifiers (such as maybe an abstract keyword) and decorators, it will become even more useful and valuable.

As a thought experiment, we can try to imagine other languages that have classes or class-like constructs such as structs without the ability to declaratively define their elements. Imagine Java/C++/Python classes, or Go/C/Rust structs, without the ability to declare their elements (note, not assign, just declare). Even Ruby, though it doesn't have the ability to declare fields directly in its class definition, does have a dedicated syntax for them which allows them to retain much of the benefits (statically analyzable at definition time, tooling support, etc). I think the value of declarative element definition is pretty clear in all of these cases.

My personal experience is that this has been invaluable. It makes the shapes of objects much easier to understand and work with, and the tooling support and the ability to do meta programming via decoration (in, again, a declarative way) has made day to day work within JavaScript much less error-prone and easier.

For these reasons, I think that the ability to declare public fields/properties is absolutely a necessary thing to add to the language, in some form. I would love to see counter-proposals with alternatives to the current private syntax that start with this assumption, and design in such a way that their private syntax would not interfere with this ability. I believe that both Classes 1.1 and #264 would not do this - that is to say, they would sacrifice the ability to declare public properties in order to have a slightly better (from some perspectives) private syntax.

Does class field assignment cause bugs?

So, if we accept that public fields declarations are a requirement, then the next question becomes: How should assignment work? There are roughly 3 possibilities:

  1. Class field values get assigned to the prototype of the class, just like object declarations.
  2. Class field values get assigned to the instance of the class, as in this proposal.
  3. Class field declarations cannot be assigned a value at all, directly. Instead, you must assign a value in the constructor of your class (or via manipulating the prototype directly?)

The issues with instance assignment are well known, and in my experience, they are not wrong either. This is definitely a confusing thing the first time it is encountered by users. I think both [Set] and [Define] semantics are confusing in their own ways, and I somewhat prefer [Set], but either way it's definitely not intuitive that an accessor cannot truly override a field. This problem is somewhat exacerbated by decorators, which can convert a field to an accessor (in most iterations of the spec).

However, I have also had direct experience with the alternative of assigning field values to the prototype. We built a class model with these exact semantics in Ember.js, and have used it for almost a decade now, and the amount of bugs and other issues this has caused is massive. In part, it's counterintuitive to how users think a class should work, and it also leaks constantly, with code beginning to actually rely on the shared state (once users figure out that it does that). We even have an ESLint rule to prevent this specifically for this reason.

Static fields enable the same pattern, but they do it in a way that is much more obvious that it is happening. This also allows users the ability to choose, instance objects/arrays, or static objects/arrays, and this is very valuable in the common case.

Finally, not allowing assignment at all prevents static assignments as well, which, as @ljharb has pointed out, do not have an ergonomic alternative at all. They can't be inlined into the constructor, and will instead require directly modifying the prototype, which is not ideal IMO.

The main point here is, all possibilities for assignment contain pernicious bugs and issues that users will have to learn. The only possibility that does not is no assignment at all, just declaration, and that severely limits usability and expressiveness. The proposal currently does the opposite - it accepts some pitfalls that can be learned and avoided, in favor of the most expressive option that enables the most functionality.

Conclusion

As a framework contributor who has worked with class fields and decorators for a few years now, I find their functionality to be very well thought out and designed. Yes, there are pain points, but they were not unneccesary pain points - they were the result of prioritizing certain functionality, such as declarative public fields, and matching private state, during the design process. This was done with much community feedback from many different early adopters - Ember.js, Typescript, React, Salesforce, etc. etc. And ultimately, I think that the solution presented really does choose the least-bad (and in many cases, the best) solution that matches all of the various constraints.

In my experience teaching users about the gotchas, they are generally pretty easy to learn and they move on fairly quickly. They haven't limited our ability to write classes in our frameworks or caused major issues either, and I don't expect them to.

Finally, I also just want to reiterate in closing, I do understand the criticisms and I think everyone who has contributed to all the various threads on this repo has valid points and concerns. I don't mean to come off as dismissive of them, rather, I'm mainly making this comment because I haven't seen many users from the community defending class fields and their benefits on here (which I suspect is because they have been working well for many folks, and they don't feel the need to check in), so I wanted to add that perspective to the conversation.

@fabiosantoscode
Copy link
Author

Thanks for your detailed rebuttal. I would however argue that:

  1. For the case of tooling etc, it's not hard to loop through the statements of the constructor and look for assignments to this properties. It's just that, with the exception of tern.js, nothing has been doing that thus far.
  2. There are many ways to allow for declaring class fields without confusion.

Something which would remove the ambiguity about when field values are evaluated, would be something like C++ has, which is the definition of what the arguments are after the closing parens of the constructor. It's a bit hideous, but it would look a little something like this:

class Point {
  constructor(x, y)
    x = x
    y = y
  { }
}

const p = new Point(10, 20)
p.x // 10
p.y // 20

Besides declaring the fields, this even allows you to define their values according to the constructor parameters. You can also directly destructure objects and shove values into the fields. By knowing the types of the arguments to the constructor, tooling would know the types of the arguments and assign these types to the fields by extension. It's not the prettiest syntax in the world but it makes it pretty obvious that it executes multiple times.

@pzuraq
Copy link

pzuraq commented Oct 5, 2019

For the case of tooling etc, it's not hard to loop through the statements of the constructor and look for assignments to this properties. It's just that, with the exception of tern.js, nothing has been doing that thus far.

It is possible to create heuristics for knowing which class fields exist, but it is not possible to know all possible fields without evaluating the constructor. Consider:

constructor(...args) {
  setupMyObject(this, args);
}

Because the constructor is a function, all bets are off in the end. Anything can happen, any amount of method calls, branching, etc. Yes, the community could try to establish some norms about this, but it would be difficult and spotty at best.

Additionally, this method would not allow for decoration/modification in a performant or declarative manner, e.g. it wouldn't be possible to add abstract syntax to the language in the future and allow the definition of abstract fields.

There are many ways to allow for declaring class fields without confusion.

Absolutely! And as I pointed out in my post above, I would love to see counter-proposals that attempt to explore this space. The most popular counter-proposals so far seem to avoid doing so, in order to prioritize what they see as a more ergonomic private field syntax.

The syntax you proposed does seem interesting. I feel like it would change a different norm in the language - that nothing comes between the closing paren and the beginning curly bracket of a named function definition. This may be confusing too, and I wonder if it would parse well, etc. But it's worth exploring, for sure.

Edit: Additionally, is there precedent in other languages for something like it? It's always useful to look at other designs to see what the pitfalls were, and how well it works 😄

@chriskrycho
Copy link

FWIW, I've found in actual practice for about three years now that the teaching curve here—which is one of the things people seem to object to most—is minimal. I've been helping people come up to speed with this proposal for a couple years now via TypeScript (including having taught conference workshops on TypeScript to groups of devs who included people who had never even seen class syntax before), and… it just isn't a problem. It's a one-sentence explanation: "Writing a property assignment runs in the constructor, right after the super call if there is one." (Uses of static are far fewer in my experience; and that's another single sentence explanation.) Whether in workshops, chat, etc., class property assignment (static or not) has never once caused someone to get confused on explanation. That it requires explanation is… well, the same as literally every other language feature.

Let's make that very clear, over and against claims that it's merely a matter of people who've already learned it who find that it's fine: in the process of my teaching it, it has never caused any confusion.

What's more, as @pzuraq noted above, it's a much better solution in this space than the existing alternatives, which historically have tended to use mixin-style prototype extension, or explicitly doing MyThing.prototype.whatever. The combination of static and instance property assignment has very clear and well-defined semantics, is in my experience easy to explain to newcomers to class syntax (whether or not they're deeply familiar with JS' prototypal inheritance and semantics), and is extremely well-proven-out by existing users of it throughout the ecosystem.

@rdking
Copy link

rdking commented Oct 6, 2019

@chriskrycho As one of the dissenters, I don't believe I've ever argued that the learning curve was difficult. It definitively isn't. Instead, I've always argued that the implementation is not intuitive, i.e. it requires an explanation. Most of the other language features don't in fact require much in the way of explanation. ES is very much in line with other C-style OO languages. So knowledge of those other languages ports pretty quickly with minimal explanation.

Let's make this very clear: there is no point in adding a feature in a non-intuitive way when an intuitive approach exists that renders results more in line with current best practices and usage patterns.

@pzuraq First, nice post. You've really thought through this. However, you missed on a few points.

  1. Public properties in a class declaration aren't necessary at all. For that matter, neither is class. However, now that we have class, the lack of ability to declare public properties can be palpably felt.
  2. What's actually unnecessary is the concept of "fields". Java, C#, and C++ have fields. However, those languages also provide separate instance data spaces for class in the inheritance chain. That's what allows the "field" concept to make sense. ES class does not provide for these class-specific instance data spaces. This is the source of the problems with "fields" in ES. If they knew they were going to take this approach, class shouldn't have followed the ES5 pseudo-class style.
  3. It is actually possible to declare data properties in a class, even without a constructor function. See this for details.
  4. There's more than 3 possibilities on how assignment could work. You missed 2 of the least destructive ones:
    • Put the "field" on the prototype, perform the assignment in the constructor.
    • Put the "field" on the prototype, perform the assignment of primitive literals on the prototype and everything else in the constructor.

Splitting the declaration and initialization like this preserves TC39's desire to use [[Define]] semantics while simultaneously preserving the functionality of accessors and the layering of inheritance.

My point is simply that for all the problems this proposal will cause, there is a viable solution in a different approach that will otherwise render precisely the same observable semantics. The depressing fact is that TC39 was either unaware of or failed to come to consensus about any of those approaches.


I'll say this: This proposal will no doubt prove to be useful in a good way for many developers. However, it will also prove to be disruptive and problematic for many others, even those who aren't directly using the features it provides. It's this second thing that's the core problem.

@ljharb
Copy link
Member

ljharb commented Oct 6, 2019

With Set or Define, fields would work identically, for the record.

@rdking
Copy link

rdking commented Oct 6, 2019

@ljharb Your statement is confusing. Assuming you're talking about my point 4: if Define semantics are used in the constructor, the prototype chain gets shadowed. The point of moving the declaration to the prototype is so that it can be defined in a way that doesn't cause prototype chain shadowing. The constructor-time assignment should not use Define semantics.

If that's not what you were referring to, please clarify.

@pzuraq
Copy link

pzuraq commented Oct 6, 2019

@rdking thank you! And thank you for your response, I appreciate the dialogue 😄 it's also clear you've thought through these things too, and care a lot about them.

ES is very much in line with other C-style OO languages. So knowledge of those other languages ports pretty quickly with minimal explanation.

I feel like this is a bit of a reach. It has a lot of similarities to many other languages, some C like ones, and many others as well. There are enough differences that most developers will have to learn a few things, as with any language, so I think it's reasonable to say that things may work a bit differently in this language. There are certainly plenty of things that aren't intuitive at all, such as the binding behaviors of this.

  1. Public properties in a class declaration aren't necessary at all. For that matter, neither is class. However, now that we have class, the lack of ability to declare public properties can be palpably felt.

This is true, but this is also true about all language features, including any possible private state solution. I feel like the fact that we're all here discussing future language features means it is a foregone conclusion that we would all like to see the language evolve (and if not, all language changes are backwards compatible, so of course it should be possible to continue using older styles indefinitely).

  1. What's actually unnecessary is the concept of "fields". Java, C#, and C++ have fields. However, those languages also provide separate instance data spaces for class in the inheritance chain. That's what allows the "field" concept to make sense. ES class does not provide for these class-specific instance data spaces. This is the source of the problems with "fields" in ES. If they knew they were going to take this approach, class shouldn't have followed the ES5 pseudo-class style.

This is true, and it would be nice if we could transition to a different inheritance model, but again I think it's ok for behaviors to diverge somewhat between languages. The overall semantics are similar, which is why the same keywords were chosen. The behaviors are a bit confusing, but ultimately I think most people pick them up well.

This is mainly working with what we have, in a non-disruptive way. If we can't make improvements to the language because we can't match other languages exactly, we won't be able do much in the end. I think the other features that TC39 has shipped, classes included, show that there is plenty of value in adding new features despite differences and quirks.

Edit: Thinking on a bit more, I think another important thing that makes this "make sense" in the way that you mean in Java, C#, and C++ is that their classes cannot have arbitrary properties added to them at any time. If they could, then it would lead to split of behavior, with some fields/state living in their inheritance tree and some living only on the instance.

  1. It is actually possible to declare data properties in a class, even without a constructor function. See this for details.

I am aware, Ember has had similar meta programming capabilities wrapping prototypes for some time. However, we need a language level solution in order to have shared tooling, and other shared features like decorators/modifiers.

  1. There's more than 3 possibilities on how assignment could work. You missed 2 of the least destructive ones:
  • Put the "field" on the prototype, perform the assignment in the constructor.
  • Put the "field" on the prototype, perform the assignment of primitive literals on the prototype and everything else in the constructor.

I believe the first solution here is effectively the same as option 2 from my first post. Even if a value were assigned to the prototype, the assignment in the constructor would be done on the instance, and wouldn't take the value in the prototype into account at all.

The second solution seems plausible from an implementation perspective, but I imagine could be the source of many hard to track down bugs. Users would have to be aware that sometimes a value will interop nicely with the prototype chain, but other times it will not. That seems worse that always choosing one or the other.

However, it will also prove to be disruptive and problematic for many others, even those who aren't directly using the features it provides. It's this second thing that's the core problem.

Can you expand more on why you believe this will be problematic for users who aren't directly using these features? It's true that superclasses could potentially override subclass behavior, but that's already true today with classes and class constructors (and something that framework authors have to be aware of, we've had plenty of experience with this in Ember). Do you think that subclassing library/framework classes will be cause lots of disruption? Can you give some concrete examples of the type of disruption you would not want to see?

@rdking
Copy link

rdking commented Oct 15, 2019

@EthanRutherford The syntax itself isn't too surprising. However, the semantics are. Consider the following:

class Base {
  alpha = 1;
}
class Derived {
  alpha = 2;
  print() {
    console.log(this.alpha + super.alpha);
  }
}

It's rather surprising that this does not work given how much work was put into making fields behave as they would in a compiled language. It isn't as though it's not possible. I just recently built a library called ClassicJS that does exactly that. It could have been even better if I had the ability to implement this in-engine.

The problem is that this is only 1 of many cases where the method of shoehorning in compiled language semantics breaks in the face of ES' prototype-based nature. What about this:

class Base {
  alpha = 42;
}
class Derived {
  alpha() { /* do something */ }
}
(new Derived).alpha(); // Throws!

I'd say it's pretty surprising to find that this.alpha === 42. That won't happen in any compiled language. These are the kinds of things I mean when I talk about surprises and gotchas.

@EthanRutherford
Copy link

To my knowledge, neither of these two cases would work in a compiled language either. Overridden parent methods can be called from a child class in some such languages, but I've never seen anything with such a concept as shadowed parent data being possible, let alone accessible. All languages I know of would override the value, and that's what I would expect.

As for the second example, again this is not possible in a compiled language: you'd get a syntax error of some kind. While the result here would not be what we're necessarily expecting, I'd also say this is very clearly a logical error by the author. If we're following OO practices, the base class sets up a contract, and attempting to change the value of alpha from a number to a method is a violation of that contract. I'd possibly appreciate some kind of error in this case, but the semantics make sense when you consider what the analogue for this is:

class Base {
    constructor() {
        this.alpha = 42;
    }
}

class Derived extends Base {
    // even if left unspecified, this is what the implicit constructor would do
    constructor() {
        // this.alpha would be set to 42 here!
        super();
    }
    alpha () // etc.
}

I wouldn't want the above to work in the way you suggest it should, because then code operating on the Base type would completely crash if given an instance of Derived. That would completely go against the whole point of inheritance, and violates the open/closed principle, one of the 5 major principles of OO.

I understand you may not come from an OO background, so I'd just like to communicate that, as someone who does, these are by and large the semantics I expect.

@rdking
Copy link

rdking commented Oct 16, 2019

@EthanRutherford Sorry about baiting you like that. I figured the direct approach was too easy to argue against.

To my knowledge, neither of these two cases would work in a compiled language either.

Nice work. You caught it. Yet you still missed it somehow. Why? Simply because this is exactly how JS works normally. ES is a prototype-based language, not a compiled one. Trying to force in compiled language behavior causes somewhat or completely surprising result, especially when only half-done.

In the first case, ES makes no real distinction between the types of data stored in a property of an object, and a prototype is just that: an object. As such it doesn't make a distinction between keys with null, undefined,()=>{},or 42. All ES cares is that there is a property on the object. Hence the truth behind the first problem is that this works:

let a = {
  __proto__: {
    alpha: 1
  }
  alpha: 2,
  print() {
    console.log(this.alpha + super.alpha);
  }
};
a.print(); // 3

and this is what is both possible and reasonable to expect from an implementation of class with data in ES. Not being able to do this in a prototype oriented language is surprising.

In the second case, since a derived class does nothing to check the members of a base class, you cannot expect that developers will not do something like this. Am I saying it's good design? Heck no? But if you're going to go so far to work around a well-known, easily managed foot gun, you should also put in work to prevent things like this that will produce obviously hard to find errors. The usual case is that Base may not be in the same module as Derived with one being from a library and the other being developer code. Certainly it's not expected that the developer will crack open the possibly minified and/or obfuscated library to check for naming conflicts.

This is yet another reason why the prototype is a better choice. You said it yourself

If we're following OO practices, the base class sets up a contract...

That contract is the prototype. If we're going to rely on engine magic to store a portion of that contract in a concealed location, then we're going to need to rely on even more magic to verify the contract is being upheld. That simply isn't currently the case. And as such, for every somewhat rare time someone makes this mistake, there's going to be a surprising error waiting for them.


That was fun. I haven't tried bait and switch for a long time. I wasn't sure you were going to bite. I'm curious about your opinion on something though. What about this case?

class B {
  A = class A {...};
  ...
}

Should there be multiple versions of class A? One for each instance of class B? Or would it be better to have a single copy of class A stored somewhere common to all instances? After all, B::A is just a property of B. ES doesn't care what's on the right side of = as long as it's assignable.

@rdking
Copy link

rdking commented Oct 16, 2019

I understand you may not come from an OO background, so I'd just like to communicate that, as someone who does, these are by and large the semantics I expect.

BTW, I wasn't around when OOP was born (SmallTalk), but I did have the pleasure of watching it grow up (C++), watching it experiment with interesting ideas and mature(PHP, Perl, Python, Object Pascal, Object COBOL), only to see it used (C#) and abused (Java) only to be partially (ES) or completely (recent functional languages) abandoned by its users.

It's definitely a good thing that more OO structure is coming to ES. However, TC39 needs to be exceedingly cautious about how it carries out their designs. Trying to shoehorn in features that belong to compiled languages while not taking the proper precautions to ensure safety with prototype compatibility in a prototype-based language is pure folly.

But that's precisely what this proposal is doing. It takes the popular "best practices" of today and codifies them while simultaneously writing off as insignificant the core nature of the very thing being enhanced. Having 2 sources of truth for a single class is not good, especially when they can conflict in the very ways you pointed out.

One of the members of TC39 said that they cannot fix the prototype foot-gun without essentially changing the language. That's true. However, causing the language to drift away from its prototype-based nature by introducing an unarguably new and partially incompatible means of creating a template changes the language all the same.

@EthanRutherford
Copy link

Should there be multiple versions of class A?

Yes. If I wanted a single version, I'd use the static keyword. Even before this proposal, I would still never think to put the class on the prototype, I'd use B.A = class { ... }.

@rdking
Copy link

rdking commented Oct 16, 2019

@EthanRutherford

Yes. If I wanted a single version, I'd use the static keyword.

Well, I can't say I disagree with you on that. However, I also recognize that there are sometimes circumstances (usually due to a bad design somewhere) when being able to have an "internal" class that is consistent across instances is useful. Static won't be able to give you that unless it's also private.

Where we disagree is in that there should be multiple instance of class A. To be frank, the code I gave should probably be treated as an error in most cases. But when you consider that it cannot be treated as such in all cases, that's when you run into foot guns. Replace the class with a singleton that is not allowed to be static or private, and you get a legitimate foot gun generating situation.

My point remains though. Having 2 sources of truth for the contract provided by a class is folly. Either the contract must exist in the form of a prototype, or the contract must exist in the magic internal format. Splitting it between the two is what's causing issues.

@ljharb
Copy link
Member

ljharb commented Oct 16, 2019

It's already split, because JS constructors have always been able to (and have done, in the community) install properties on this that don't exist on the prototype. Prior to ES6, many builtins did the same - for example, in ES5, http://www.ecma-international.org/ecma-262/5.1/#sec-15.10.7 were all own properties (most were moved to prototype accessors in ES6 when it was web compatible).

The perspective you have - that the prototype is "the contract" or the "single source of truth" - is totally fine to apply to your own code, but is simply not something guaranteed by the JS language - ever, or now; by the spec, or by the community at large.

@rdking
Copy link

rdking commented Oct 16, 2019

@ljharb

It's already split, because JS constructors have always been able to (and have done, in the community) install properties on this that don't exist on the prototype.

Seriously? The constructor is not a source of truth for what the class provides. It's merely another function that just happens to run before anyone receives the instance. That function could very well do something like this:

class Ex {
  constructor() {
    if (Math.random() > Math.random())
      this[Symbol()] = Math.random() * Number.MAX_SAFE_INTEGER;
  }
}

What's the truth there? All you know is that some Symbol is being applied. You don't even know the value of that Symbol. Nor can you claim that it exists on all instances. Contrived? Yup. But it proves my point. I've seen code where the constructor applies externally defined data to the instance using externally defined keys. That's just as bad as what I've done here.

My point? Initialization does not provide a source of truth. If it's not part of the class definition, it's not guaranteed to exist. Therefore, prior to this proposal, the only source of truth for an instance is its prototype.

@ljharb
Copy link
Member

ljharb commented Oct 16, 2019

The constructor could mutate the prototype, or shadow something on the prototype. There simply is no source of truth for what a class instance looks like except reading the code and knowing what it does, and this proposal doesn’t change that (except to make reading the code easier by making more things declarative)

@rdking
Copy link

rdking commented Oct 16, 2019

The constructor could mutate the prototype

... and change the truth of all instances

or shadow something on the prototype.

... altering itself locally while still containing the truth in its prototype.

I'm surprised you didn't bring up the fact that the constructor could swap out or completely remove the instance's prototype. Not that it would have helped your argument. Changing the instance's prototype changes the class it belongs to, and therefore the truth about the instance.

There simply is no source of truth for what a class instance looks like...

Incorrect. If a is an instance of A and A.prototype.hasOwnProperty(k) then k in a. That's the truth.

@ljharb
Copy link
Member

ljharb commented Oct 16, 2019

No, it’s not the truth universally - you’re simply incorrect here.

@rdking
Copy link

rdking commented Oct 16, 2019

A statement with no evidence? C'mon. That's not your style. Should I give you a hint? There's only one counter argument for what I just claimed. However, you previously decried it as bad practice.

@ljharb
Copy link
Member

ljharb commented Oct 16, 2019

“is an instance of” also has no concrete definition given Symbol.hasInstance, so I’m also not clear on what you’re claiming. But if you claim a counter argument exists, then automatically you’re claiming it’s not a universal truth ¯\_(ツ)_/¯

@rdking
Copy link

rdking commented Oct 16, 2019

You just named it. Symbol.hasInstance is capable of changing the outcome of instanceof and breaking the relationship I described above. I will gladly concede that this is an exception. The runner up argument would have been a Proxy around an a with configurable properties, allowing Proxy to lie about what's available. However, both of those are edge cases. If the claim I made couldn't be reasonably relied on, then much of the code that exists today simply wouldn't.

My point? Even without universality (which was only broken relatively recently), prototypes are the closest thing you've got to a source of truth for what can be reasonably guaranteed to be found in an instance of a class.


BTW, where did I claim the source of truth to be "universal"? Don't remember making that claim.

@ljharb
Copy link
Member

ljharb commented Oct 16, 2019

Therefore, prior to this proposal, the only source of truth for an instance is its prototype.

This statement is false, because “the closest thing to a source of truth” is objectively not the same as “the only source of truth”. This means it’s not an axiom of the language that’s being changed, only an axiom of your personal mental model.

@rdking
Copy link

rdking commented Oct 16, 2019

Let's look at the possibilities prior to this proposal:

  1. The prototype - once applied, provides its properties to every instance without exception. They can be shadowed by the instance, but they still exist. If a property is remove from the prototype, it is removed from all instances. This is active and runtime.
  2. The constructor - provides initialization on a per-instance basis. Is subject to parameters and conditional logic, if any.
  3. A Proxy - Wraps the instance and is !== the instance.

Of all the possibilities, the only one that consistently provides the same properties to all instances is the prototype, hence "the only source of truth". Nothing else comes close enough to even apply. So let me re-state my assertion more clearly:

For any non-exotic object a, if a.[[GetPrototypeOf]]() === A.prototype and A.prototype.[[HasProperty]](k) then k in a.

@ljharb
Copy link
Member

ljharb commented Oct 16, 2019

Even setting aside the edge cases, all you’ve got is that “if it is in the prototype, it is in the instance” - the reverse isn’t true.

@rdking
Copy link

rdking commented Oct 16, 2019

The reverse isn't true even in compiled languages. Just because it's in an instance doesn't mean it has to be part of the template. The only guarantee is supposed to be that if it's in the template, it's in the instance. It's a 1-way relationship.

@ljharb
Copy link
Member

ljharb commented Oct 16, 2019

And, the same is true for public fields. It's not on the prototype, just on the instance.

Exactly as you say - "Just because it's in an instance doesn't mean it has to be part of the template". Thus, just because it's an own property doesn't mean it has to be on the prototype, to use your own definition of template.

@rdking
Copy link

rdking commented Oct 16, 2019

That's the catch. Those public fields are part of the class and yet not part of the class template. That means there's a separate source of truth. Once declared, it is guaranteed that the resulting instance properties declared by the fields will exist prior to the first post-super line of code in the constructor. Such guarantees are the point of having class in the first place.

My point? A class provides guarantees about the shape of the instance it produces, even to its own constructor. If there's more than 1 source for those guarantees, then you've created a problem.

@ljharb
Copy link
Member

ljharb commented Oct 16, 2019

So too are all static things, which aren't on the prototype.

@rdking
Copy link

rdking commented Oct 16, 2019

Red herring fallacy. Static things are not part of the instance. A class does indeed provide guarantees about the initial state of the constructor function. However, that's a slightly different topic.

@ljharb
Copy link
Member

ljharb commented Oct 16, 2019

It's not a red herring or a fallacy. Class bodies currently contain two kinds of things: the constructor, and methods, which have a "placement" of static or prototype. This proposal makes class bodies contain a third kind of thing: fields, that are private or not, and also have two possible placements.

Nothing about this is a different topic. You continue to claim that everything in the class body defines the shape of the instance, but that is false - being inside the class body offers no inherent guarantee that it's related to the instance at all. This proposal doesn't change that.

@rdking
Copy link

rdking commented Oct 16, 2019

Class bodies currently contain two kinds of things...

True, but wrong things.

  1. Methods (of which the constructor is only 1). Accessors also go in this category.
  2. Data

Till now, ES class has only support methods. Data had to be supplied on a per-instance basis or clumsily attached to the prototype after the class was defined. There's another way to do it, but it seems most people either didn't consider it or wasn't aware of it. This proposal is attempting to provide the data support, but it is doing so in a way that avoids the already existing template. Because of the difference between the 2 sources of truth, conflicts are arising.

You continue to claim that everything in the class body defines the shape of the instance...

Over generalization fallacy. I never said "everything in the class body", did I? I've chatted with you enough to understand why you'd make that leap, but it takes you to the wrong place. A class body defines 2 things using the things it contains:

  1. The shape of the class template.
  2. The shape of the constructor function.

This proposal seeks to add a 3rd thing to this list:
3. The shape of the instance beyond the class template.

That's out of scope for every existing class in every language. Sure, the semantics have been worked out to resemble what compiled languages are doing, and that's great. However, it ignores part of the nature of a class in doing so. Specifically, the part that says things defined by a base cannot override anything defined by a derived. From where I sit, that's a death knell. You killed the very thing you were trying to enhance.

@rdking
Copy link

rdking commented Oct 16, 2019

I'm going to pop open a different thread. I want to share with you a way to fix this without giving up any of the decisions made by TC39.

@a-ejs
Copy link

a-ejs commented Apr 25, 2020

"placement" of static or instance

prototype, not instance.

@ljharb
Copy link
Member

ljharb commented Apr 25, 2020

Thanks, fixed.

@littledan
Copy link
Member

The design of this proposal was largely based on prior work by TypeScript and Babel, which have included fields with this syntax for many years. It seemed to us in TC39 that many developers were happy with these designs in terms of developer intuition, even though they had the property described in the original post.

This proposal has been at Stage 3 for years, and is shipping in several JS implementations, so we will not be making a change based on this thread.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

10 participants