tc39 / proposal-class-fields

Orthogonally-informed combination of public and private fields proposals
https://arai-a.github.io/ecma262-compare/?pr=1668
1.72k stars 113 forks source link

Field declarations overwrite properties on the prototype #151

Closed mweststrate closed 5 years ago

mweststrate commented 5 years ago

I think this issue has been raised a few times already in several of the open issues, like #100, but I thought it would probably be good to report a very specific, real life use case.

The following snippet is the official way to enhance a class with observable capabalities in MobX, when decorator syntax is not used. (See decorate docs)

class Counter {
  counter = 0
}

decorate(Counter, { counter: observable })

Which is currently semantically equivalent to:

class Counter {
  @observable counter = 0
}

This first example works fine with TypeScript and babel-plugin-proposal-class-properties, but only if loose mode is used (that translates the initializes to an assignment in the constructor). In standards mode, this mechanism no longer works.

The simple reason is, the decorate call introduces the count property on the Counter property, and gives it a getter and setter. If just an assignment is made in the constructor, this works fine. However, if a new property is introduces, the property on the prototype is completely ignored and a new one is introduced on the instance.

In pseudo code decorate does something along these lines:

class Counter {
    counter = 3
}

Object.defineProperty(Counter.prototype, "counter", {
  set(counter) {
    this._counter = counter * 2 
  },
  get() {
    return this._counter * 2 
  }
})

const counter = new Counter()

console.log(counter.counter)

Printing the counter will yield 12 in loose mode, but 3 in standards mode, as in standards mode the field would be re-introduced in a non-interceptable way.


I search this repo for it, but couldn't really find it, what was the motivation to change the spec in this regards? (or if it never changed; what is the motivation to deviate from what existing transpilers are doing?).

And more specific: how could I express "decorating" my class fields in the future with the above API? After all, changing a prototype after the initial class declaration is not that uncommon in JavaScript.


A potentially future risk of this approach is that it will render MobX completely incompatible with create-react-app, if this proposal is finalized before the decorators standard, as the decorate utility is currently the only way in which CRA users can obtain decorator-like behavior without the syntax. For some background: https://mobx.js.org/best/decorators.html

N.B. note that this problem also happens when the field is declared, but never initialized.


Edit: for clarification to other readers of this issue, the difference in compilation between loose and standards mode is:

// Loose:
var Counter = function Counter() {
   this.x = 3 // assignment being picked up by property on the prototype
}

// Standard:
var Counter = function Counter() {
   Object.defineProperty(obj, "counter", { // Property definition hides prototype property
      value: 3,
      enumerable: true,
      configurable: true,
      writable: true
    });
};
yyx990803 commented 5 years ago

@mbrowne the child class should explicitly use the @observed decorator as well. It makes the intention clearer.

Also to clarify a bit: I retract my statement about agreeing to use [[Set]] semantics as long as there's an alternative syntax... I think it should use [[Define]], regardless of syntax.

Again, I find the disagreement has a lot to do with how people looks at the syntax:

The reason I believe it is and should be different from assignment has been explained in an example I raised above, comparing a field declaration to a variable declaration.

Let's think about a variable declaration, without an initializer:

let foo;

This declares a LexicalBinding that, even without an initial value, will shadow a variable with the same name in the outer scope.

Now we can give it an initial value by adding an initializer:

let foo = 1;

Notice the foo = 1 part looks exactly like an assignment statement. But no dev would confuse it with an assignment statement because of the let before it. We know this = 1 is the Initializer as part of a variable declaration, so it's not going to affect the foo in the outer scope.

Again for class field declarations, if we have the following:

class Foo extends Bar {
  foo;
}

With [[Define]] semantics this is a FieldDefinition without an initializer similar to let foo;. It declares a ClassElement, just like let foo declares a LexicalBinding.

With [[Set]] semantics this is an awkward noop... it's like let foo not doing anything if there's no initializer for it.

Now in order to give the field an initial value we add an initializer:

class Foo extends Bar {
  foo = 1;
}

Again, foo = 1 looks like an assignment statement, but it's not. It's an Initializer as part of a FieldDefinition inside a class body.

I think this is easy enough, if not easier to spot than a variable declaration. How hard is it really, to tell the difference between class Foo { foo = 1 } and this.foo = 1 in a constructor? Or are we simply too invested in code that relies on the wrong semantics to be able to move on? (I agree that the migration cost can be an issue, but I'm not sure if that should be the reason to use (in my opinion) wrong semantics.)

mweststrate commented 5 years ago

As a partial mitigation for the issues @mweststrate raises, we could permit decorators to transform a field declaration into Set semantics, even if the default is Define. That possibility is discussed in tc39/proposal-decorators#44 . I would be interested in your feedback on that proposal.

@littledan I am not entirely sure how it relates to the mentioned issue, so are you proposing:

In a decorator on a field on the super class, generate a property descriptor that has own placement, in that case it will be run before the subclass initializers, and if placement is own and configurable: false, there would be a runtime exception? (placing on own with a setter would still not trigger the setter if I read the [[define]] proposal correctly, and the definition introduced by the superclass decorator would just get lost)

...Or was the proposal more like: use a decorator on the subclass, like demonstrated with @assign above?

rdking commented 5 years ago

@yyx990803 You make a good argument, but it is woefully flawed.

If the current proposal were offering public properties instead of fields, it would be perfectly ok to use [[Define]] semantics as both the prototype and the constructor function are products of the class keyword. You've supported your argument with a false comparison.

yyx990803 commented 5 years ago

@rdking Regardless of using [[Set]] or [[Define]], class fields can affect class instances in both cases - when the parent class doesn't have a setter, [[Set]] does create an own property on the instance. Per your argument, class fields should not be added to the spec at all.

justinfagnani commented 5 years ago

On the Polymer team, we've recently seen an uptick in problems related to define semantics, with two slight variations.

The first problem is happening because we generate accessors for a field name, and then the user also defines a field for the initialization, expecting the value to hit the setter.

We generally document that in standard ES2017 users define a static properties getter to declare which accessors to generate:

class El extends LitElement {
  static get properties() {
    return {
      field1: {},
    };
  };
}

In the interest of not duplicating language features, we don't include a way to initialize a value in the properties declaration, so we recommend that initial values set initial values in the constructor. But users already used to using compilers often do this instead:

class El extends LitElement {
  static get properties() {
    return {
      field1: {},
    };
  };
  field1 = 'initial';
}

With define semantics, this shadows the accessors we create and now elements don't re-render when the property is set. We can't really warn in this situation either.

Decorators help somewhat, because we can use the same declaration for accessors an initializers:

class El extends LitElement {
  @property()
  field1 = 'initial';
}

But then this falls down with inheritance:

class El extends LitElement {
  @property()
  field1 = 'initial';
}

class SubEl extends El {
  field1 = 'new initial';
}

There are at least some users that expect that the property is declared already, and this will only initialize it to a different value.

Without decorators, this is equivalent to writing accessors in the base class:

class A {
  set field1(v) { this._setProperty('field1', v); }
  get field1() { this._getProperty('field1'); }
}

class B {
  field1 = 'initial';
}

The intention of the setter in the base class is to observe change to the property, and here this breaks down. I'm really unsure that users are going to expect the difference between the initializer and an assignment in the constructor.

Another problem with define semantics is with mixins. Mixin might often want to declare a field, and without knowledge of the specific base class they're applied to, not overwrite an existing field:

const MyMixin = (base) => class extends base {
  field1 = 'default';
  go() { console.log(this.field1); }
}

class MyBase {
  @property()
  field1;
}

class A extends MyMixin(MyBase) {
  // ...
}

Since there's no way to conditionally define a field, mixin fields will always shadow base class fields. Fields become much more fragile.

I wish class fields had been designed to implicitly define accessors on the prototype that access implied private storage, but only if they didn't already exist in the class hierarchy. Then declarations would not conflict, and fields could be overridden with accessors with super calls:

class A {
  field 1 = 'default';
}
class B {
  set field1(v) { super.field1 = v; }
  get field1() { return super.field1; }
}
rdking commented 5 years ago

@yyx990803

@rdking Regardless of using [[Set]] or [[Define]], class fields can affect each class instance in both cases - when the parent class doesn't have a setter, [[Set]] does create an own property on the instance. I don't see your point.

V8 seems to disagree with you. Here's what Chrome said...

>  class A {
       get x() { return this._x }
    constructor() { this._x = Math.PI; }
   }
<- undefined
>  class B extends A {
     constructor() {
       super();
       this.x = 3.14;
     }
   }
<- undefined
>  (new B).x
x  VM588:4 Uncaught TypeError: Cannot set property x of #<A> which has only a getter
       at new B (<anonymous>:4:16)
       at <anonymous>:1:2
   B @ VM588:4
   (anonymous) @ VM602:1
yyx990803 commented 5 years ago

@rdking /facepalm do you really have to read it so literally? To be more accurate, when the parent don't have a property with the same name, or the property is a plain property (no getter/setter).

class A {}
class B extends A {
  foo = 2
}

Can you tell me with [[Set]] does class B get an own property foo?

Heck, even with just class A { foo = 1 }, the class declaration is going to add an own property on every instance of A. I don't see how your argument supports using [[Set]] over [[Define]].

yyx990803 commented 5 years ago

@justinfagnani FWIW, isn't it possible to warn about user accidentally shadowing accessors by checking Object.getOwnPropertyDescriptor(el, 'field1') after instance creation? Depends on where you define the accessors, the return value should either be undefined (defined on prototype) or has a setter (defined on instance). If the user shadowed it, it would be a descriptor without a setter, and that should trigger a warning.

ljharb commented 5 years ago

I believe fields are created after the super call, so a parent class constructor has no opportunity to interact with them.

yyx990803 commented 5 years ago

@ljharb yeah, I realized it's a bit tougher for custom elements because the instantiation is handled by the platform...

rbuckton commented 5 years ago

I believe fields are created after the super call, so a parent class constructor has no opportunity to interact with them.

I think this is a huge problem with the [[DefineOwnProperty]] semantics.

Have we considered C#'s approach to shadowing superclass elements:

class Base {
  get x() { return this._x; }
  set x(value) { this._x = value; }
}

class SubNoShadow extends Base {
  x = 1; // does not shadow, triggers setter on Base
}

class SubShadows extends Base {
  new x = 1; // shadowed, does not trigger setter on Base
}

C# allows you to use the new keyword to mean that you want your member definition to shadow a member in the superclass.

If we went with [[Set]] semantics and this, I would imagine the above example would have the following runtime semantics:

class Base {
  get x() { return this._x; }
  set x(value) { this._x = value; }
}

class SubNoShadow extends Base {
  constructor() {
    super();
    this.x = 1; // does not shadow, triggers setter on Base
  }
}

class SubShadows extends Base {
  constructor() {
    this.x = 1; // shadowed, does not trigger setter on Base
  }
}
Object.defineProperty(SubShadows.prototype, "x", { 
  value: undefined, 
  writable: true, 
  configurable: true, 
  enumerable: true 
}); // shadows `x` on Base with own property
mbrowne commented 5 years ago

@yyx990803

Again, I find the disagreement has a lot to do with how people looks at the syntax

I agree, and I can understand both perspectives regarding the expected meaning of the syntax; we're all biased one way or the other. But I would be surprised if more than 1 in 10 developers expect = to mean [[Define]] when they see it in a class property. This figure is just a guess, and but it's an educated guess...It's not just because = always means [[Set]] currently for object properties, but also because ES6 classes (and before that, constructor functions) are already widely used and the current convention is [[Set]] (it's also how class properties in Babel 6 worked unless you deliberately enabled spec mode). Even with a different syntax of x = 1 instead of this.x = 1, I think most developers will expect that class properties are just sugar for the established convention. Changing this convention would introduce an unnecessarily confusing inconsistency between classes that initialize properties in the constructor and those that initialize them using class fields (and classes that use a mix of the two). Understanding how classes translate to prototypal behavior under the hood is confusing enough already...

class Foo extends Bar {
  foo;
}

With [[Set]] semantics this is an awkward noop... it's like let foo not doing anything if there's no initializer for it.

I don't think it should do nothing in that case, but rather just be a shorthand for foo = undefined

(I agree that the migration cost can be an issue, but I'm not sure if that should be the reason to use (in my opinion) wrong semantics.)

While they clearly don't match your expectations and preferred behavior, I don't know why [[Set]] semantics would be "wrong" (to be clear, I don't think [[Define]] semantics are "wrong" either, just an ill-advised choice for JS). But you did say this is your opinion so I suppose you realize this :)

rdking commented 5 years ago

@yyx990803 Your question doesn't have a single answer, so let me break it down for you.

class A {}
class B extends A {
  foo = 1
}

Can you tell me with [[Set]] does class B get an own property foo?

By my way of thinking, class B is B.prototype, but even if we go the other way around nothing changes. If all we're doing is taking the class-fields proposal and changing this 1 semantic, then no. The problem is that foo is not a property of any of the products of class B. Whether [[Set]] or [[Define]] is used, foo wouldn't be a member (own property) of B or B.prototype under this proposal.

However, if we also make class only affect what it owns so that your sample code cause there to be a B.prototype.foo, then not only would [[Define]] be the correct thing to do, but foo would also be an own property of B.

do you really have to read it so literally?

Yes, I do. We're talking about issues where the semantics really matter. Being figurative runs the risk of having a misunderstanding cloud the meaning and ruin communication. To me, it's highly important that TC39 not bugger this issue. This post flat out confirms everything I've been saying. It doesn't get any more clear than that.

ljharb commented 5 years ago

That comment deals with Define vs Set semantics alone; if the proposal used Set, I believe it would resolve those issues.

yyx990803 commented 5 years ago

@rdking so you want class fields to declare prototype properties instead. I'm afraid that's rather off-topic for the set vs. define debate, where the pre-assumption is that it works on the instance, not the prototype.

rdking commented 5 years ago

@yyx990803 I do, but that's not my argument. Please don't chase red herrings.

rdking commented 5 years ago

Clarification. I brought up the issue with declaring class data fields on the prototype only as a means to show when it would be perfectly ok to use [[Define]] semantics.

hax commented 5 years ago

I brought up the issue with declaring class data fields on the prototype only as a means to show when it would be perfectly ok to use [[Define]] semantics.

Yeah, I think if you consider OO paradigm deeply, the only possible perfect usage for [[Define]] semantic should be on prototype, eg. declare getter/setter wrapper of a private state.

Note, in JS community there are many do not like OO paradigm. I think it's ok, you always can use other paradigm in JS, but if we are designing a feature for class, we'd better focus on OO paradigm and follow the methodology/good practice/semantics of other OO languages like Java/C# as far as we can.

mweststrate commented 5 years ago

I did a little research to how field initializers are currently explained:

In most blogs / documentation that deals with field initializers, they are sold as replacement for assignments in the constructor, not as way of making the process of declaring fields easier. Which makes sense as people hardly declare fields in the current ecosystem. (The exception is Babel's own documentation, which makes sense as the consequence of loose mode is explained there)

But it means that their is a lot of re-education to do, as examples like these are misleading:

dqkazuzvyae4lz_ (by @addyosmani: https://twitter.com/addyosmani/status/1054620939185545218)

As well as for example http://2ality.com/2017/07/class-fields.html by @rauschma: screenshot from 2018-10-23 09-03-04

TypeScript:

Initializers in instance member variable declarations are executed once for every new instance of the class and are equivalent to assignments to properties of this in the constructor. https://github.com/Microsoft/TypeScript/blob/master/doc/spec.md#841-member-variable-declarations

All those 3 examples don't mention subclassing explicitly, but I fear that the silent implication of that is that people will read that for subclasses the same principles holds: "writing a field initializer in your (sub)class is the same as writing an assignment in the constructor", because the general tendency in currently available documentation is: field initializers are just sugar for constructor assignments.

So it seems the entire community has to be re-educated with a change to [[define]]. Sure, in 95% the effect is the same, but that is like saying that you don't have to grok this in JS, because in 80% of the function calls it is what you expect it to be.

mbrowne commented 5 years ago

@hax I agree that [[Define]] only makes sense for declaring prototype members. But JS should stick to its prototypal roots rather than emulating class-oriented languages. I imagine there are others who see [[Define]] as being more consistent with Java/C#/C++/etc. Although we have seen that C# uses something more akin to [[Set]], I don’t see how Java is even relevant to this particular issue since it doesn’t have C#-style getters and setters.

rdking commented 5 years ago

@mbrowne Thank you for saying that

JS should stick to its prototypal roots rather than emulating class-oriented languages.

I've been trying my best to drive that point home through all the problems this proposal causes. If ES starts down this path, it will no doubt migrate from being "prototype oriented" to "class oriented", and in doing so, strip the language of a good portion of it's flexibility. I don't want to see that happen, even though I'm a big fan of the class paradigm.

hax commented 5 years ago

@mbrowne

Although we have seen that C# uses something more akin to [[Set]], I don’t see how Java is even relevant to this particular issue since it doesn’t have C#-style getters and setters.

Java getX/isX/setX is just getter/setter (for example https://en.wikipedia.org/wiki/JavaBeans) though the syntax is not like property. Other languages in Java platform (like Kotlin, Groovy, Scala, etc.) adopt property syntax.

And my words is "follow the methodology/good practice/semantics of other OO languages". I don't mention syntax. Even Java is using different syntax for getter/setter, the concept model of it is just same as all other OO languages. And I believe JS (the class part) should also keep the same concept model. I don't think introducing traps which never exists in other OO languages would be a small issue in any way.

rdking commented 5 years ago

@hax Actually, there's a small but very important difference. In ES and C#, accessor methods allow you to use what looks like a data property in code but with side-effects like a function. The simple fact that the underlying functions are hidden from the code means that anyone looking at the code won't know that there are side-effects to be aware of unless they look at the source of the object owning the property. Java doesn't allow for this style of code, making all getters and setters explicit.

hax commented 5 years ago

@rdking

Yes, there is a difference, but I don't think there is a very "important" difference. Allow me to explain:

In all languages which support getter/setter like data property, you can never ensure o.x just a plain read/write if o is out of your control. For example, all property on DOM object are accessors which may have special semantic. All JS developers have to understand it eventually. So when you see o.x and o is out of your control, there is no conceptual difference with o.getX() in Java.

OTOH, in many cases you may have full control of o, you know o.x can never be an accessor, it's just plain read/write. Similarly, in Java, you could use public field to make sure there won't be any side effect. But as all Java books teach you, you should only use public field in your internal implementation and never expose it as external API (field is public but the class is package-private).

So the two sides are:

In Java side, you can differentiate between simple read/write semantic and getter/setter semantic from syntax, but the best practice never allow you use simple read/write semantic on external APIs.

In JS side, you can not differentiate them from syntax, you only ensure simple read/write semantic if the objects is your internal implementation.

The final result of two sides is just same: you can rely on simple rw semantic only in your internal code, you can't deliver such semantic to outside. In Java side, you must wrap it to getter/setter to expose. In JS side you can expose data property directly but client code can never assume it's just rw but not getter/setter.

So the concept, i.e. “encapsulation”, is consistent cross languages.

ljharb commented 5 years ago

It’s not about reliance - it’s about what one’s expectation is. Certainly any o.x may have side effects, but the common expectation/intuition is that it does not. Any array could be sparse, but finding one defies expectations and violates the principle of least surprise, and so the bulk of the community strives to avoid ever using or having sparse arrays - so too do they avoid using or having non-data-properties whenever they can.

rdking commented 5 years ago

I only use accessor properties where it makes sense, like on a Rect having an top, left, bottom, right, width, & height. Changing any of the first 4 affects the last two and vice versa, but from the point of view of the developer, they should just appear to be normal data properties and not something calculated. I'll also use them for hidden or calculated read-only data. Beyond this, it would be confusing to other developers to use accessors.

mbrowne commented 5 years ago

@hax There are some important differences between Java/C# on the one hand and JS on the other when it comes to getters and setters, as I already mentioned at https://github.com/tc39/proposal-class-fields/issues/144#issuecomment-431611613. There’s no need for a rule that external APIs must avoid public properties 100% of the time, and even if we wanted to I think it would be way too authoritarian and restrictive to impose this philosophy on everyone.

hax commented 5 years ago

@ljharb

Certainly any o.x may have side effects, but the common expectation/intuition is that it does not.

What is the "common" expectation/intuition ?

  o.x = 1
  assert (o.x === 1)

Does this match "common" expectation/intuition of JS programmers or C# programmers?

Unfortunately there are many cases not match this "common" expectation/intuition, for example, e.innerHTML, a.href, localStorage.xxx, etc.

Any array could be sparse, but finding one defies expectations and violates the principle of least surprise, and so the bulk of the community strives to avoid ever using or having sparse arrays - so too do they avoid using or having non-data-properties whenever they can.

A good example!

Web platform APIs and most libraries in the land never use sparse array. So even a.forEach(x => ...) behave different to for (x of a) ..., it's not a big deal because in practise a is rarely be sparse. (But we still ask senior engineers have to aware it.)

What about accessors?

ljharb commented 5 years ago

yes, i think that does match the common expectation, with any RHS value (assuming an object and a property to assign to; that expectation need not hold for class fields). Your list of non-language HTML builtins doesn’t change that expectation, except when dealing with html.

What about accessors? Builtins have them (ES6 migrated a lot of own properties to prototype accessors) but i don’t think most developers are aware of the differences, and i think they think of them as own properties.

hax commented 5 years ago

@rdking

I only use accessor properties where it makes sense, like on a Rect having an top, left, bottom, right, width, & height. Changing any of the first 4 affects the last two and vice versa, but from the point of view of the developer, they should just appear to be normal data properties and not something calculated. I'll also use them for hidden or calculated read-only data. Beyond this, it would be confusing to other developers to use accessors.

I believe we all agree we should use accessors cautiously, both in Java and in JavaScript. The key point is, is there any difference when a programmer see o.getX() in Java and o.x in JavaScript, if o is an object out of his control?

hax commented 5 years ago

yes, i think that does match the common expectation, with any RHS value (assuming an object and a property to assign to; that expectation need not hold for class fields). Your list of non-language HTML builtins doesn’t change that expectation, except when dealing with html.

So you think it's a common expectation, but such "common expectation" can be "wrong" when dealing with html?

Then how about jsdom? how about virtual dom? Where is the line?

What about accessors? Builtins have them (ES6 migrated a lot of own properties to prototype accessors) but i don’t think most developers are aware of the differences, and i think they think of them as own properties.

Yeah, accessor always make it look like property. (but not necessarily "own property", I think.) But I don't agree it's "common" expectation/intuition that o.x = 1; assert(o.x === 1). Even in own property case, o.x could be writable: false.

Or, let me put it another way: even it's a "common" expectation/intuition that o.x = 1; assert(o.x === 1), JS programmers have to know this expectation/intuition could be false if o is out of your control.

ljharb commented 5 years ago

In strict mode, if it was nonwritable, the assignment would throw.

hax commented 5 years ago

In strict mode, if it was nonwritable, the assignment would throw.

Yeah, in both strict and non-strict mode, o.x = 1; assert(o.x === 1) would throw. 😝

rdking commented 5 years ago

@hax

The key point is, is there any difference when a programmer see o.getX() in Java and o.x in JavaScript, if o is an object out of his control?

In Java, there's a huge difference. Without looking at the code, you have no idea where the value you get when calling o.getX() comes from. With o.x, you expect that whatever you store there will still be there if you don't call a function that changes it. The expectation in JS isn't much different, except that there is a slight awareness that o.x might have accessors. Most of the time, the expectation is that it's a data field.

hax commented 5 years ago

@rdking

With o.x, you expect that whatever you store there will still be there if you don't call a function that changes it.

Notice, in Java side, there is no o.x (if o is an object out of your control) at all.

rdking commented 5 years ago

@hax

Notice, in Java side, there is no o.x (if o is an object out of your control) at all.

Not true. Most of the time, no one will write a Java object that way, but occasionally, someone will as long as the data doesn't require validation.

hax commented 5 years ago

@rdking

Not true. Most of the time, no one will write a Java object that way, but occasionally, someone will as long as the data doesn't require validation.

Effective Java

Item 14: In public classes, use accessor methods, not public fields

hax commented 5 years ago

Most of the time, the expectation is that it's a data field.

I think it's doubtful. We should agree whenever you deal with DOM, there should be no such expectation. And as I already asked in previous comments, how about jsdom? How about virtual dom? How about a arbitrary library?

I'll give you a real example.

The model class for a signup form.

const form = document.querySelector('form#signup')
const signup = new Signup()
...
try {
  signup.username = form.elements.username.value
  signup.birthday = form.elements.birthday.valueAsDate
  ...
  await callServerSignupAPI(user)
  location.href = '/signup-success'
} catch (err) {
  form.elements[err.field].setCustomValidity(err.message)
}

It seems we could expect username, birthday are just data properties, and in our version 1.0 of Signup, they are.

But...


First, birthday is a Date object, and Date is mutable (Date is a terrible API 😭 hope we will have new temporal API soon 😃), there is a risk you call someBadFunctionWhichMayMutateDate(user.birthday), so in next release or Signup, we decide adopt the good practice to make the API immutable:

class Signup {
  ...
  get birthday() {
    return new Date(this.birthdayTimestamp)
  }
  set birthday(date) {
    this.birthdayTimeStamp = date.valueOf()
  }
}

Should we change the API to getBirthday/setBirthday as @ljharb 's coding style? I think most programmers won't agree, because it break all the client codes of version 1.0 of Signup without any benefit. Actually, as semver, this could be just a patch version, aka. 1.0.1.


Second, we may introduce new feature, support national identity number. Most identity number format have verify code (check digit), so we have to use accessor.

class Signup {
  get chinaID() { return this._id }
  set chinaID(id) {
    if (!isValidChinaID(id)) throw new SignupError({field: 'chinaID', message: 'Please enter the correct ID'})
    this._id = id
  }
  get hongkongID() { ... }
  set hongkongID() { ... }
  ...
}

Could we use setChinaID()/getChinaID() instead of accessor? Perhaps. But we already use accessor (for birthday) in previous versions, it seems we'd better stick on accessor for API consistency.

So we released version 1.1 as semver.


Third, actually, China ID format already contains birthday info, and we don't want to get inconsistent data. Now we have two choices:

Both can keep the data consistency, but we might prefer the latter because it might give better user experiences.

class Signup {
  set chinaID(id) {
    if (!isValidChinaID(id)) throw new SignupError({field: 'chinaID', message: 'Invalid ID'})
    this.birthday = birthdayFromChinaID(id)
    this._id = id
  }
  set birthday(date) {
    if (date.valueOf() === this.birthdayTimeStamp) return
    if (this._id) throw new SignupError({field: 'birthday', message: 'Already use birthday from ID'})
    this.birthdayTimeStamp = date.valueOf()
  }
  ...

Should this version be 1.1.1 or 2.0?

In theory this is a breaking change, which change the behavior of the code:

signup.birthday = d
signup.chinaID = ...
signup.birthday.valueOf() === d.valueOf() // could be false now
signup.birthday = ... // could throw now

But in practice, it unlikely break things, in real usage it just provide a new custom error for some cases and bring better user experiences. So I would prefer it be 1.1.1 not 2.0.

Again, should we change the API to setXXX/getXXX?

Yes, there are something not very match "expectation/intuition" as @ljharb 's standards.

signup.birthday !== signup.birthday // true
signup.chinaID = ... // signup.birthday also changed
signup.birthday = ... // could throw if signup.chinaID != null

But it this code more match "expectation/intuition"?

signup.getBirthday() !== signup.getBirthday() // true
signup.setChinaID(...) // signup.getBirthday also changed
signup.setBirthday(...) // could throw if signup.getChinaID() != null

I very doubt.

And remember, if change to getXXX/setXXX, it just break all client codes, and you must release 2.0 , and it's possible many downstream projects just don't want to upgrade and keep use old versions.


As this example shows, I think, in a language which already provide getter/setter in property-like syntax, the usage of it is inevitable in real engineering, and as the consequence, programmers have to aware o.x could be an accessor if o is out of his control.

mbrowne commented 5 years ago

I don't recall @ljharb saying that he was against getters and setters generally. He brought up that airbnb style rule to point out performance differences in the other thread. I'm sure he can speak for himself, but why not discuss the merits of that style rule in the airbnb style guide repo as he suggested earlier? The rules that apply to a large, performance-sensitive website such as airbnb do not necessarily apply everywhere, and of course even among maintainers of large websites there are different approaches. This doesn't seem to be the right place to discuss whether or not it's a good rule.

I think your post was on-topic overall, and I think the important question is what the language should support (not app-specific design considerations). Classes already support getters and setters; that doesn't mean everyone has to use them. Similarly, the introduction of public fields doesn't mean everyone will have to use them.

rdking commented 5 years ago

@hax

Effective Java

Item 14: In public classes, use accessor methods, not public fields

Quoting a book at me doesn't change the reality of one of @ljharb's favorite statements: "if it can be done, someone will do it". What you found in the book is a best practices recommendation, not a hard and fast rule. Sometimes, the limitations of a class type is more than sufficient to constrain the possible values. In those cases, and to avoid unnecessary speed penalties due to setting up and tearing down stack frames, developers will just write plain old public properties. It's rare these days, especially with the lombok package around, but it does still happen.

hax commented 5 years ago

@rdking

"if it can be done, someone will do it".

Actually, it's my point view that getter/setter is inevitable in JS at all, using "if it can be done, someone will do it" argument 😘

What you found in the book is a best practices recommendation, not a hard and fast rule.

Well you can always do anything in all programming languages... This is why best practices exist, isn't it? 😜

For example, we have no-var rule in ESLint. Such restriction can't be the part of the language because of compatibility requirement. But I think we should agree no-var is a de facto standard of ES6+ for JS programmers, even it's not in the real standard.

In those cases, and to avoid unnecessary speed penalties due to setting up and tearing down stack frames, developers will just write plain old public properties.

I don't think "speed penalties" or "stack frames" would be the factors which most JS programmers consider when they decide whether use plain data property or accessor.

And in previous comments I already gave the fact: there is no significant performance difference between data property and accessor wrapper of a data property in morden JS engines: https://jsperf.com/data-vs-accessor-vs-getter-setter/15

hax commented 5 years ago

@mbrowne

This doesn't seem to be the right place to discuss whether or not it's a good rule.

It's not my intention to prove getter/setter is better coding style. I wrote these comments just because @ljharb said (not his words, but I think it's his meaning, correct me if I was wrong):

  1. Many JS programmers adopt Airbnb coding style, so
  2. many JS programmers agree that we should avoid getter/setter, and use getXXX()/setXXX() instead, so
  3. many JS programmers think sugar of auto generated accessor is useless (even it could satisfy all use cases of public field but avoid the footgun of public field, and much consistent to the pattern of other OO languages)
  4. so we must land public field

I think your post was on-topic overall, and I think the important question is what the language should support (not app-specific design considerations). Classes already support getters and setters; that doesn't mean everyone has to use them.

Of coz, we don't necessarily use getter/setter if data property is ok, just like we don't necessarily use class if plain object is ok.

The important thing is, if you are using class, you have the freedom to adopt getter/setter when needed, without worrying about break current code which use data property syntax.

But public field, using current syntax+semantic, as we recently discussed, could shoot you in a small probability but inevitable and random way.

Similarly, the introduction of public fields doesn't mean everyone will have to use them.

Yeah, not everyone have to use them, in fact everyone who don't want footgun have to not use them. 🤪And unfortunately, even you don't use public field, you would still be affected if your downstream/users use it, see https://github.com/tc39/proposal-class-fields/issues/151#issuecomment-431679515 (too many comments which make you can't jump to previous comment, so I copied it here)

For example, React could make the state be private and only expose getter, which make sure newcomer will not accidently write this.state = ... in event handler, and force them follow initState()/setState() usage. If that, public field which lure the users writing class C extends React.Component { state = ... } which nullify the getter/setter on base class just shoot the users and the framework.

rdking commented 5 years ago

@hax I'm actually a fairly big fan of accessor properties. There are many things in many applications that would be a lot cleaner read if things were implemented that way. Properties like enabled, readOnly, multiLine, recordNo, width, height, etc... would all benefit from this since they all look exactly like data properties but carry the expectation that something will change if they're altered. That, imo, is exactly what accessor properties are for.

That's also why I prefer them to public data properties when private properties are available. Most of the time, when a public data property is writable, it's because something is supposed to happen behind the scenes when it changes. Most of the remaining time, they're just read-only properties easily implemented with a getter. It's been painfully rare for me to create a public data property since I only do it when there's no need for a side effect.


That being said, we're definitely drifting away from the topic. Whether or not the use of accessors is a good thing or not is very much beside the point. They exist, so some will use them. Others will inherit from code that uses them. "Fields" as currently defined will clobber them and break functionality. This proposal needs to either:

  1. Keep the [[Define]] semantic but move the definition to the prototype,
  2. Switch to [[Set]] semantics and keep the definition in the constructor as current,
  3. Use [[Define]] semantics on the prototype and restrict non-primitive assignments in the definition, or
  4. Use [[Define]] semantics on the prototype, but drop assignment capability.

I could be wrong, but I doubt it. 1 keeps consistency with how ES already works and leave it up to the user to not shoot themselves for the 1 bad case. Most of us are already doing a very good job of preventing this on our own. Don't need bureaucratic help from the language for this. 2 would eliminate the foot-gun at the cost of complicating and/or eliminating some very useful programming patterns. 3 eliminates the foot-gun surgically, but forces developers to continue doing what they're already doing, using constructors to initialize instance-specific data and objects. I doubt anyone sees much utility in 4.

In case it's not obvious, I prefer 3. I'm of the mindset that if it's instance-specific, it should be in the constructor. If it's a default value for every instance, it can go in the definition. If there were a simple way of deep-cloning arbitrary objects (even native ones), then I wouldn't even bother with the restriction in 3. At the same time, I also doubt people would be as worried about the foot-gun of mutable objects in data properties on a prototype.

littledan commented 5 years ago

I've posted a PR in the decorators proposal repository to permit decorators to make a field have [[Set]] semantics instead of [[DefineOwnProperty]] semantics without the need to define a throwaway field.

mbrowne commented 5 years ago

@littledan @ljharb @bakkot: @rdking explained to me (at https://github.com/tc39/proposal-class-fields/issues/144#issuecomment-434153157) that one of the significant issues with [[Set]] semantics is how it would complicate the semantics for decorators. Is that indeed a significant concern of the committee? And if a solution were found for that, would it increase the chances of revisiting the decision to use [[Define]]?

(To be clear, I'm talking about the default behavior. In either case I'm totally in favor of being able to override the default by using a decorator.)

ljharb commented 5 years ago

Decorators will be roughly equivalently difficult to implement either one with either default, imo.

rdking commented 5 years ago

If it's all the same, then why bother with the not-backwards-compatible [[Define]] semantics? Certainly there's got to be a reason for breaking existing code.

ljharb commented 5 years ago

I don’t agree it breaks any existing code, since there’s no existing code using class fields.

The reasons for choosing [[Define]] are because many members of the committee felt that it is important that all members of a class body be like an object literal - always behaving with define semantics. There were some, like me, who preferred Set - like the code that would otherwise be in a constructor - but we ended up settling on Define. That won’t change with your proposal - and if it changes, it would change with the current proposal too

rbuckton commented 5 years ago

@rdking:

  1. Keep the [[Define]] semantic but move the definition to the prototype,
  2. Switch to [[Set]] semantics and keep the definition in the constructor as current,
  3. Use [[Define]] semantics on the prototype and restrict non-primitive assignments in the definition, or
  4. Use [[Define]] semantics on the prototype, but drop assignment capability.
  1. Using [[Define]] to install a field and its value on the prototype is in almost all cases a major footgun that should be avoided.
  2. The [[Set]] semantic not only matches most user expectations, it also matches the semantics used by both Babel and TypeScript, so a very large percentage of the community is used to this.
  3. Restricting non-primitive assignments is a non-starter and would make this feature unusable in a significant number of cases.
  4. I'm not sure what you mean by "drop assignment capability".

Unless I am misunderstanding your options here, I feel there is another option to consider (though I would still place it below [[Set]] semantics):

  1. Use [[Define]] to install a property with an undefined value on the prototype (shadowing the property in the superclass), but use [[Set]] to assign it in the constructor per-instance.

This would allow subclasses to define an accessor that can override/shadow the field on a superclass, which the current proposal does not support.

mbrowne commented 5 years ago

I get it; IMO the issues with Define are really bad, but they're still edge cases after all. But in the absence of any equally bad issues with Set...well, that part I don't get. A theoretical idea that members of a class body should be like an object literal seems like a pretty weak argument compared with the much more practical issues this causes, and also considering the inconsistency with how instance properties were always previously defined (at least conventionally) using functions and prototypes and nowadays with ES6 classes.

I think there are ways to measure what's more ergonomic for developers empirically and not just subjectively, but there's no point in pursuing that if there's no chance that the committee will reconsider their decision.

If the decision is to go against the wishes and semantic expectations of the larger community, that should not be done lightly. There should be a really compelling reason for it that outweighs negatively impacting developers with unexpected footguns. As I said before, my opinion could be wrong both technically and in terms of what would ultimately be best for developers. But I think I speak for everyone in favor of Set semantics when I say we'd like to see and understand the analysis of tradeoffs that led to this decision even if we don't agree with it. I'm not asking for a whole FAQ like the one for private fields. Just something, even just a comment here, that talks about tradeoffs and directly addresses the problems with Define semantics that have been raised here.