-
Notifications
You must be signed in to change notification settings - Fork 113
Field declarations overwrite properties on the prototype #151
Comments
Just answered this here. It's also in the notes at least here and I think in other places, though I can't dig them up just now.
Surely people who want to trigger setters and not use decorators can still do so in the constructor? |
I hope we can enable this kind of decorator by adding a feature to decorators, as we discussed. You can also see this summary of Set vs Define by @bakkot . |
Thanks for the pointers! Had a hard time finding them myself, as there are so many issues already around this area.
I don't think people write code in terms of "I want to trigger a setter". That is exactly the kind of low level detail I want to abstract away as a library author. But yes, using the constructor would be a valid work around, but the biggest problem it poses: how are people supposed to migrate over (after a few years of Babel and TS). The current proposal, although I do see the consistency of it, makes it quite impossible to detect mistakes ("plz use constructor instead of initializer"), so I see potentially a lot of issues filed with this problem, without being able to assist users as early as possible. Which is basically the same issue as the "deprecation setter" as mentioned in #42. This patterns are extremely valuable for libraries to hint users in the right direction with fast feedback, and I the pattern is heavily used in libraries such as React as well. For example when state is assigned directly in a For me the clearest way forward would be to treat assignments as what they always have been; imperative statements rather than declarations, which would make the class body consist of two types of things. This would be consistent with the other class members that I see, as there is a clear syntactic difference with a declaration Counter self argument: Summary:
|
@bakkot The problem that now yet another developer is trying to get you to see is that the approach you're taking for implementing public fields goes against the general expectation that developers have about what is supposed to happen. By avoiding the prototype in parsing the definition, you avoid the foot-gun of accidental static-like data in what's expected to be an instance property. That's reasonable. By using a property definition in the constructor instead of a simple set operation, you completely ignore the natural side effects of having a prototype. That's called blind-siding the developer for no gain. This is what I mean when I mention breaking existing functionality just to avoid something you think of as a foot-gun. |
What is the most significant foot-gun the current proposal is trying to prevent? I'm still not clear on that. @bakkot gave several reasons for not using [[Set]] and I'd like to make sure that first of all we're actually talking about the same things. And it had better outweigh major foot-guns like the one @hax raised. Let me outline why the issue he cited is so problematic... Suppose you start off with this: class ExistingGoodClass {
x
}
class Subclass {
x = 1 // in the subclass we want a different default value
} IMO allowing redeclaration of the same property like this in a subclass isn't a good idea in the first place (you can already assign default values in the constructor, so restricting this wouldn't result in any loss of functionality, and would avoid potential confusion). But if we're going to allow it as the current proposal does, then we have to consider what happens if the author of ExistingGoodClass decides to refactor to a getter/setter pair, reasonably assuming that this shouldn't break any code outside the class itself and that subclasses will inherit the getter/setter: class ExistingGoodClass {
get x() {...}
set x(v) {...}
} But actually, the subclass won't inherit the getter/setter, and I'm guessing any decorators on the parent class wouldn't apply either, since the instance property defined by Subclass would take precedence. But it occurred to me that there's actually a bigger problem, which wouldn't be helped by using [[Set]] instead of [[CreateDataPropertyOrThrow]]. Suppose we started with just this: class ExistingGoodClass {
} Now suppose someone adds a subclass and the author of ExistingGoodClass isn't aware of it: class Subclass extends ExistingGoodClass {
get x() {...}
set x(v) {...}
} Later on, the author of ExistingGoodClass decides to add a field class ExistingGoodClass {
x
} Now the instance-level Perhaps this issue is unavoidable given how the language already works, at least if we want some equivalence with traditional OOP in JS and developer expectations of default placement (instance vs. prototype). I'll have to think about it more... But the fewer such issues the better, IMO...we can at least prevent the first issue by using [[Set]]. I think to do otherwise would be violating the prototypal inheritance model of JS and how most developers would expect it to work. And just make it harder to understand what is going on. |
I thought about it some more and realized that using [[Set]] instead of [[CreateDataPropertyOrThrow]] actually would help with the second example as well. With [[Set]] semantics, this: class ExistingGoodClass {
x
} Would be functionally equivalent to this: class ExistingGoodClass {
constructor() {
// DOES respect the getter/setter defined by subclass if there is one
this.x = undefined
}
} |
Clear overview @mbrowne. I think the most important observation here is: the first pattern is regularly applied in practice (field -> getter), and the second case is unavoidable (after all, this is what happens with normal method overrides as well). So for that I would simply argue the model: define properties by default, make exception for field declarations and assignments, as that is how the language is used extensively "out there", both Babel and TS are compiling code like that for years. One could make an argument that it is not the most consistent proposal possible (I think I would agree to that), but IMHO that can't outweigh the argument that the current proposal doesn't make it possible to migrate, or even detect code that assumes the old way of working. So unless there is a clear migration path (for example, alternative syntax), this proposal simply doesn't look backward compatible to me. Or, put differently: https://twitter.com/SeaRyanC/status/1052621631594553344 Edit: well, and what you set in your last comment :-) |
@mbrowne I don't know what @bakkot's answer will be, but I can show a few quick problems with public properties on a class in ES. We have to remember not to think about how proposal-class-fields intends to implement this. Keep in mind that everything inside a var counter = 0;
function tool() {
var a = ('a').charCodeAt(0);
var letter = String.fromCharCode(a+(counter%26));
return { [`${letter}${counter++}`]: counter };
}
class A {
x = tool();
} If everything inside the Now for the problems:
Seen from this perspective, the whole issue of defining public properties in a class definition leads to problems. Solutions:
What this means is that, (imo) only approaches 1 and 3 are even remotely viable. Since approach 3 can be performed programmatically within the |
Everything inside the class definition is certainly not part of the prototype - all static things are attached to the constructor, and the constructor is the constructor. |
@ljharb Good catch. However, |
@rdking, continuing from #150 (comment):
I think I agree with you, although it looks like maybe there's a typo in there (was the "is illegal" in the first sentence what you intended to write?) Yeah, I can see overriding getters and setters as being useful. I don't see the usefulness of redeclaring a data property, no matter whether the subclass is keeping it a data property or changing it to accessors. |
Here it is in a different way:
|
Here I would raise an opposite case: in Vue (v2 with class Parent extends Component {
// this is a reactive "data" property
foo = 1
// this is a computed property with no setter
get bar() {
return this.foo
}
} Now when a child class extends the parent like this: class Child extends Parent {
bar = 2
someMethod() {
console.log(this.bar) // should this be 1 or 2?
}
}
Notice that even with the Using the With fields using class Child extends Parent {
constructor() {
this.bar = 2
}
} This requires the developer to be aware of the underlying semantic differences, but I would argue the developer would need to be aware of such differences regardless. With class Child extends Parent {
constructor() {
Object.defineProperty(this, 'bar', { value: 2 })
}
} So:
The thing that makes |
If we use [[Set]], how would decorators which change the property descriptor work? class Base {
get x() {}
set x(v) {}
}
class SubClass extends Base {
@writable(false) x = 2;
} Should we just ignore the decorator? |
If we really want definition semantic, we have several options:
Choose what you like, but please do not just leave the footgun... |
Not confusing at all. Incorrect though. Remember, everything inside a class Child extends Parent {
_bar = 2
get bar() { return this._bar; }
set bar(v) { this._bar = v }
someMethod() {
console.log(this.bar) // should this be 1 or 2?
}
} There really is no need to have public properties go and redefine things on the instance. If you want to re-define a public property, do it explicitly either in the class definition as above or in the constructor with
You might want to check that again. With [[Set]] semantics, given your
This is the very nature of object oriented inheritance. Part of the behavior of a child is dictated by the parent. If that were not the case, inheritance would be a completely useless concept. Those who already think it is simply write code in a way that eschew vertical inheritance in favor of horizontal inheritance (inherit by encapsulation).
That would be reversing the default behavior of ES. Not a good move unless you're trying to confuse almost every developer out there. Essentially, |
That depends. Just follow existing behavior. If I have this already: a = {
__proto__: {
_x: 0,
get x() { return this._x; },
set x(v) { this._x = v; }
}
};
//What would this code do?
Object.defineProperty(a, "x", () => {
var retval = Object.getOwnPropertyDescriptor(a, 'x');
retval.writable = true;
return retval;
}); |
My wording was off, I meant parent prototype properties vs. getter/setters. (edited, although you read it wrong too), what I meant is this: class A {
get bar() {}
set bar() {}
}
A.prototype.foo = 1
class B extends A {
constructor() {
super()
this.foo = 2 // shadows
this.bar = 2 // does not shadow
}
}
I'm only debating the behavior for the class field declarations, not the behavior of set operation itself. My point is that it's fine for field declarations to have a different semantics from set, when manual set is always available inside the constructor. Using define for class fields does not take away anything.
I have no idea what you are talking about. It's not reversing any behavior at all. |
It takes away from predictability by changing what This is like if someone said |
I'd say it more along the lines of declaring a variable vs. assigning a variable: // because of the `let` we know this is going to shadow `foo` in an outer scope,
// if there is one
let foo
// or
let foo = 1
// this we know is going to rely on knowledge of outer scope
foo = 1
// because this is a class field *declaration*, we know it's going to shadow `foo`
// of parent class, if there is one
class Foo extends Base {
foo;
// or
foo = 1
}
class Foo extends Base {
constructor() {
// because this is an explicit set *operation*, we know it's going to rely on
// whether parent class has a foo getter, so when we read this piece of code
// we'd know to check the Base implementation to make sure we understand
// what's going on
this.foo = 1
}
} BTW, I'm aware that the define semantics breaks TS, especially when declaring properties without initializers. I think it could be worked around with Yehuda's On the other hand - if there's a way to tweak the proposal so that
|
I don't think that we need two different syntaxes, since we can quite easily change the the declaration behavior to assignment (or assignment to declaration) using a decorator: class Base {
set x(v) { alert("Set x to " + v); }
get x() { return 0; }
}
class SubClass extends Base {
x = 1;
}
var s = new SubClass();
alert("x with declare is " + s.x);
class SubClass2 extends Base {
@assign x = 1;
}
s = new SubClass2();
alert("x with assign is " + s.x);
function assign(desc) {
return {
kind: "field",
placement: "own",
key: somehowGetPrivateName(),
descriptor: desc.descriptor,
initializer() {
this[desc.key] = desc.initializer.call(this)
}
};
}
function somehowGetPrivateName() {
return "___private___" + Math.random();
} |
This question only comes up if the language allows changing an inherited property from a data property (i.e. a property with a class Base {
x
}
class SubClass extends Base {
@someDecorator
get x() {}
@someDecorator
set x(v) {}
} For instances of SubClass, the As for other use cases, I agree with @rdking's proposed rules for changing inherited properties, in which case I don't think we'd have any issues with decorators:
|
Decorator usage So what a code reviewer could do? Check the whole class hierarchy to make sure no And, we should remember, there is already tons of Babel/TS code using assignment semantic, strictly speaking, the authors of these code never think about the semantic difference we discussed today, the code just work! But if we change the semantic, they are all forced to check every Well, to be honest, this is one of the most tricky footgun I see in my 20 years engineering... |
@nicolo-ribaudo I've pointed this out before somewhere else, but I'll do it again here for your sake. When creating an object literal, each key is applied using [[Define]]. This makes perfect sense given that these objects are new and don't even have a prototype. When using the What this proposal intends to do is weird. Adding items to the If one of the goals of any proposal is to make sure that the new feature resembles ES, this part of the proposal is an utter failure. |
Yes, that is my whole point. If this is a blank slate problem, I think In that sense it is totally different for changes that have been made in for example decorators stage 2; the syntactic changes can statically be found and fixed, and the semantic changes can be detected at runtime (decorators are called with different arguments at runtime, so libs can detect that and implement a both the old and new behavior). However, with the And honestly, I didn't really find much real life use cases where I wanted to re-define a property in a subclass (stubbing in unit tests is the only one I could come up with). The case the other way is pretty common though, a subclass wants to change the initial default value of a field declared in a parent, but keep behavior of that assignment as defined by the parent (just being a plain field, or performing invariant / type checking, printing deprecation messages etc). So, imho, these assignments should all do the same, rather than deviating for one of the 3. class A {
x = 1
constructor() {
this.x = 1
}
}
new A().x = 1 Actually, I would predict that if With Actually, the problem here is even worse then normal variable shadowing, because with normal variable shadowing earlier closures are bounded to earlier variable definitions, but with classes earlier definitions (the base) get actually 'bound' to the newer field definitions in the subclass! So this actually reduces the control and containment of base classes over their own fields, rather then increasing it. So how are we supposed to help users? "you are shadowing a base field. Either pick a new field name or move the initial value assignment to the constructor". So I think we would end with a very consistent, but completely useless feature if If being able to define is that valuable, just add separate syntax for that
|
@yyx990803 I don't think your example is a realistic use case, or if it is then it's a very confusing design. Ordinarily when subclassing and using an existing property name (in ES6), one expects the property to use any getters and setters defined in the base class (as long as they haven't been overridden). Using the same property name in a different way as if it's a totally separate class is a recipe for confusion. BTW, does Vue even recommend using inheritance with components as a way to add additional reactive data properties? Based on my experience with React components, which I think is similar enough to apply here, you'd be better off using composition...React users have found that using inheritance in this way causes a lot of problems. When it comes to components, inheritance is generally reserved for inheriting helper methods, not creating a specialized version of an existing UI component. |
Since class declarations are so important to frameworks, and we're seeing a discussion between two framework maintainers, I asked some more framework authors what they thought about this issue.
Historically, leaders from Ember (@wycats) and React (@sebmarkbage) were deeply involved in TC39 at the time that the committee came to the decision of Define. @sebmarkbage's coworker at Facebook @jeffmo championed the Define semantics, and my understanding is that React didn't and doesn't have a particular stance on this issue. @wycats argued for Set, raising most of the issues discussed on this thread (including Babel/TS compatibility and working well with metaprogramming constructs), but agreed to move forward with Define. TypeScript was also represented in TC39 at the time and, like React, my understanding was that they were OK with either outcome. I think the opinions expressed here were based on a pretty full understanding of the problem space and the tradeoffs, and that we've had a pretty thorough discussion of the issue. Given the stable differences of opinion for this tradeoff, I'd propose that we stick with the existing TC39 consensus of Define semantics. 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. |
@yyx990803 I'm guessing that the use case you presented is indeed a realistic one. Sorry, on the surface it looked like a contrived example to me. I didn't realize until now that you are the creator of Vue. I always try to treat everyone with respect and I stand by my opinion, so in that sense my response would have been similar regardless of whom I was replying to, but maybe I misunderstood something... Just to confirm, does your example reflect a real use case for subclassing Vue components, or is it more theoretical? |
@littledan I get why the library authors would choose define semantics. The problem is that there's nothing in the language that makes it non-cumbersome to replace a definition in a base class. With decorators, however, this seems to be a moot point. a decorator |
Yeah, that's tricky. However, I think sticking to the standard that everyone already knows ( This keeps any problems that already exist (in the programmer ecosystem) right in the same place, without creating new problems. Everything works exactly as it currently does, as everyone already knows and expects.
Exactly. |
Honestly, if Had Honestly the actual utility of one over the other isn't entirely a huge difference, people would only treat inheritance differently (f.e. preference given to methods instead of accessors, if Switching from the de facto (I still prefer |
[[Define]] is the standard when declaring and initiaizing object properties; [[Set]] is the standard when assigning to them. The argument generally stood on the idea that anything declaratively placed in the class body is declaring and initializing things, not assigning to them - hence, class fields should be consistent with initializing properties in object literals, not consistent with assigning properties to objects. |
No, |
@trusktr i'm afraid you're incorrect: var parent = { set foo(v) { throw new Error('oops'); } };
var child = {
__proto__: parent,
foo: 2,
};
child.foo === 2 // no exception |
@ljharb That's a highly misleading example. Let's take a look at the standard way to initialize properties in a class, which is what this proposal is (not objects, as you've shown). class A {
set foo(v) { throw new Error(); }
}
class B extends A {
constructor() {
super();
this.foo = 2;
}
}
new B(); When is the last time you used |
@ljharb You've found an edge case that doesn't disprove what I said because most people don't write code like that, and Mozilla DN (the de facto source of web/JS documentation) even tells people not to use |
@trusktr even without @jhpratt the comparison isn't to "how you have to do it now in |
@ljharb My concern was with your example. It is misleading because you demonstrated the behavior of inheritance within a simple object, not how code is typically used today. I understand what you're trying to show, but you failed to demonstrate it. Sure, I'm in favor of |
@jhpratt certainly i have not, and I advocated for |
In case anyone missed it, a section was added to the readme about this issue: It didn't convince me that Define is the better default, but at least it hints at the reasoning behind the decision. (I still don't understand why the downsides of Set were considered more important than the big practical issues with Define, but it seems the committee was fully aware of both sides of the argument when they made their decision.) |
Here's the fun part, both of you are right. It's a nuanced issue. When declaring an object literal, all properties are indeed defined onto the new object. Reason? Everything being created is a direct product of the syntax being evaluated. Define is therefore the correct approach. On the other side..... When declaring an object factory (like a class), everything is declared onto either the prototype or the constructor for the same reasons as above. Both are products of evaluating the This is where the board is ignoring over a decade of developer usage patterns. A |
The above discussion was great to read. It makes me glad to see so much care taken in this decision. I've got a use case where Observable Elements using Proxy For CanJS 6, we are trying to make class-based HTML elements observable. For example, I'd like something like the following to work: // Define a custom element:
class HelloWorld extends StacheElement {
static view = `<h1>Hello {{this.subject}}</h1>`;
subject = "World";
}
customElements.define("hello-world", HelloWorld);
// Set the subject and have it update the HTML:
var el = new HelloWorld();
document.body.append(el);
el.subject = "Earth"; The idea is that we want to create an event when We use proxies to detect changing arbitrary properties. The problem is that custom element instances can not be proxied. So we proxy |
@justinbmeyer If fields had been properly designed, you could just use a getter/setter for The only options you have are to either completely avoid using public fields and/or go back to initializing them in the constructor. The reality is that they (assuming I understood what I was told correctly) knowingly chased conflicting concerns, and their choice of resolution blocks seemingly unrelated use cases. |
@justinbmeyer I haven't looked into it in detail, but it might be possible to use the current babel plugin for decorators to implement what will hopefully eventually be possible natively (https://github.com/tc39/proposal-decorators#set) — a decorator to specify that specific fields should use Set rather than Define. |
@mbrowne Thanks for the tip! Unfortunately, decorators are a non-starter for us from a design perspective. Our goal was to make the framework/technology work with "today's" browsers. We want something that works without a build step (ie babel). As the decorators spec is under its third revision, it seems like it's not relevant for even "tomorrow's" browsers. It might be available in the browsers "the day after tomorrow". |
@justinbmeyer How about an alternate solution? It's ugly, and loaded with boilerplate, but it will give you exactly what you want. This is how I used to write classes before I started making Proxy-based solutions. It's also the basis of the SweetJS macro I'm working on: let HelloWorld = (function() {
//Put private members in a `this`-keyed object
const pvt = new WeakMap;
//Define static private members as variables here
let _view;
// Define a custom element:
class HelloWorld extends StacheElement {
constructor() {
super();
let p = {
subject: "World"
}
pvt.set(this, p);
_view = `<h1>Hello {{this.subject}}</h1>`
}
static get view() { return _view; }
static set view(v) { _view = v }
get subject() {
let p = pvt.get(this) || {};
return p.subject;
}
set subject(v) {
if (pvt.has(this)) {
let p = pvt.get(this);
p.subject = v;
//fireEvent(....);
}
else {
this.subject = v;
}
}
}
return HelloWorld;
})(); |
Just wanted to add that there is a performance advantage to the JSBench test: https://jsbench.me/o6kt78il9e/1 Purely theoretical? Well, in my case, it was the root cause underlying a mysterious change where a frequently-called section of my code was changing from taking 10ms to >300ms to complete. The cause? I had enabled the TypeScript useDefineForClassField flag, which switches Typescript from It took me a while to track down, because I didn't expect that something like switching to the new version of class fields would make my performance-critical code 30x slower. (now that I know the cause, I can rewrite that part to avoid classes of course; but it's a drawback to keep in mind, especially for other library authors using classes in large for-loops) |
@Venryx That's not an inherent difference between set and define. That's a difference between TypeScript's output. For example, the define path looks up Object.defineProperty every time it defines a property instead of just dereferencing a local. |
@Venryx what if you compile to native class fields (i.e. with a target of |
I added that to the benchmarking test. While native class fields is faster than a manual |
@Venryx That benchmark uses function with [[Set]] semantics, not class with [[Set]] semantics. Anyway, these benchmarks show that The proper way to test this would be to implement class fields with |
The fact that TypeScript (and presumably other tools), given the code: class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
} ... have to emit this monstrosity (if you supply suitable compiler flags, which people are already using, e.g. Deno hardcodes it): "use strict";
class Point {
constructor(x, y) {
Object.defineProperty(this, "x", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "y", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.x = x;
this.y = y;
}
} instead of: "use strict";
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
} ... will definitely have far-reaching performance impact on real-world codebases. That sucks. To me, instance field initializers are just a nice syntactic sugar. I never wanted them to have semantics that can't be performantly transpiled, raising complex trade-offs between performance and correctness. At least the browser support is quite high: https://caniuse.com/mdn-javascript_classes_public_class_fields I think what's different for people using TypeScript is that defining your "fields" is the norm for every class, because that's where the types go. It's been a core part of the language since day 1. Whereas for JS programmers, it's a new-fangled language feature they opt into. Even if native fields are too slow for a Point class that you are constructing thousands of, why would you insist on using "fields" in that Point class, anyway? Whereas with TS, even your most performance-sensitive code already uses fields. So your existing code gets slower when you switch to the ES semantics, and then you have to wait a few years for browsers to iterate on their JS engines to bring your code back to the performance it had, and for browser support to be truly universal, all for some semantics you probably didn't want in the first place. |
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)Which is currently semantically equivalent to:
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 thecount
property on theCounter
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:Printing the
counter
will yield12
in loose mode, but3
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.htmlN.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:
The text was updated successfully, but these errors were encountered: