-
Notifications
You must be signed in to change notification settings - Fork 113
Honour class property values set by parents #242
Comments
This seems like it might be related to https://github.com/tc39/proposal-class-fields#public-fields-created-with-objectdefineproperty? |
This is typescript not matching spec semantics. |
@jridgewell Typescript is just used here for illustration; the actual culprit appears to be Here's an example in Babel: class Parent {
constructor () {
this.observables.map(o => this[o] = 1)
}
}
class Child {
x
get observables () { return ['x'] }
} gets turned into ..
function Child() {
_classCallCheck(this, Child);
_defineProperty(this, "x", void 0);
}
... Your expectation that Typescript doesn't match the spec semantics supports the belief that most developers won't expect this. |
What's the reason for explicitly declaring |
I’m also not clear on how common this pattern is - I’d find it very strange to see any parent class that depends on knowledge of its children. |
@nicolo-ribaudo it's mandatory in Typescript for type-definitions, but also needed for e.g. property decorators. |
That is a typescript problem (for which I have proposed a solution a few months ago: microsoft/TypeScript#12437 (comment)). Also, a decorator can easily remove the property if it shouldn't actually be initialized by the class constructor. |
@ljharb it's an abstract factory; it's routinely used in database/datastore models, view models for observables. I use it in Knockout.js/tko all the time (I help maintain Knockout), but I know it's also quite common with mobx and other libraries that have hundreds of thousands of users. |
One use case I would use to explicitly declare x in this case is readability and documentation: class Child {
/**
* The x field description
*/
x;
get observables () { return ['x'] }
}
A similar pattern is used in lit-element: https://lit-element.polymer-project.org/guide/properties#declare |
@nicolo-ribaudo It seems Typescript itself doesn't exhibit this problem; it's only when coupled with |
That's a good argument for doing it.
Typescript uses non-standard behavior. Babel is following the spec behavior, so you're hitting it there. |
@brianmhunt TypeScript already claimed to fix this (making it |
I hadn't realize this was a topic discussed ad nauseam already, but I think @mbrowne correctly assesses it:
This is mirrored by the comments @RyanCavanaugh wrote on the linked issue:
I'm here because implementations have trickled down & out into the developer tools and this issue is causing significant real-world pain. I feel the reservations expressed by @mbrowne and @RyanCavanaugh are on-point, in which case I am a canary in the coal mine, and lots more pain is coming. 🦜 To speculate on the opposite scenario, if I'm not sure what the standard is for modifying a proposal at this stage, but I think this is one worth giving a good, hard look if it's not too late. |
Nothing's changing, because you can't evidence someone out of a position they didn't evidence themselves into. The committee looked at 6+ years of TypeScript developers using |
If I understand correctly this has nothing to do with |
@bakkot is correct here. Both set and define semantics would have overridden the super class’s prop with the subclass’s. The issue is only with declaring a field and expecting it to do nothing. |
I think maybe it makes sense for TypeScript to act differently here since in this case you're declaring a property just for the purpose of giving it a type annotation. But certainly in plain JS, I think it's usually an anti-pattern to re-declare a property already present in a parent class when all you're doing is inheriting it. IMO properties should generally only be re-declared in subclasses when you're overriding the default value. |
The problem is a compile-time vs run-time issue. Where Typescript can skip the issue (barring the presence of the proposal plugin) because it has compile-time knowledge of the structure of the derived classes, native ES (and babel's proposal plugin) only apply the subclass' fields to the instance after the base class constructor has run its course. There's only 2 semi-reasonable ways out that I can think of:
Given the example in the OP, this would mean that |
It’s an important invariant imo that the parent constructor doesn’t get to run any code after |
It's not clear to me, would this code work? class Parent {
constructor () {
this.properties.map(o => this[o] = 1);
}
}
class Child extends Parent {
x = this.x;
get properties () { return ['x'] }
}
const c = new Child();
assert(c.x === 1); |
Yup, that’ll work. |
One thing that might help here would be an ESLint rule enforcing what I said above; i.e. this would be an error: class Base {
x
}
class Sub extends Base {
x // cannot re-declare `x` without giving it a different default value
} but this would not: class Base {
x
}
class Sub extends Base {
x = 1
} I think the following would also be OK, since it's explicit, although I'm not sure why you'd need to do this (maybe if you were using Flow to give it a type annotation?): class Sub extends Base {
x = undefined
} In any case, it's important that field declarations without default values get set to class AClass {
x
constructor() {
// this should output ["x"]
console.log(Object.keys(this))
}
} |
@ljharb The parent constructor wouldn't be running any such code after super returns. That would still be very much impossible. Here's an example. Suppose you had the following classes:
What I've suggested is that
The difference here is that instead of fields from Point2D and Point3D being applied immediately after the super call in their own constructors, those fields would be applied immediately following the creation of the instance object by the base class before the base class constructor's code is executed. |
Applying the child fields that way is running child code before the parent constructor is done. |
Also note, each constructor could choose to return a new object in which the child’s fields should instead be applied. |
@mbrowne The ESLint rule, while helpful in its own right, wouldn't solve the problem presented by the OP. In the example @brianmhunt gave, |
@ljharb Is it because the field initializers are themselves functions that depend on the current state of the active instance? In that case, I agree with your assessment that the idea shouldn't be allowed, but with caveat. It falls back to what IMO was a bad design decision regarding initializers. I still believe that initializers should be run at declaration time instead of at construction time. That would've mitigated the argument you're making.... but I get that one of the goals was elimination of explicit constructors. Not fond of that goal either. In either case, you're left with a situation that is otherwise unsolvable for the OP. No constructor of any ancestor class will ever be able to indirectly manipulate all fields of the instance. That's just a design flaw(trade-off). |
That is indeed the design decision, which matches how pre-es6 inheritance works. |
To an extent, yes. However, pre-ES6 inheritance also commonly accommodated data properties on prototype objects. The mere existence of the knowledge that putting an instance-specific object on a prototype property is a foot-gun is proof of this. I said that to point this out: the design decisions that were made were neither the best, nor even among the best of the available options, not for |
Off topic clarification of the previous postJust a note: I'm not trying to incite an off-topic discussion, it's just that the phrase:
seems to imply that the design decisions were based on the only way pre-ES6 inheritance works, when the reality is that ES6 inheritance has multiple modes, most of which depend on the prototype object, which has never been required to only contain methods. Nor has it been exclusively used in that way by the community. While the doors may be closed over the questionable but somewhat reasonable choices regarding |
Common point: They both ask for modification of semantics which TS users used and relied several years. And they both cause breaking change which is subtle to caught but may bring significant bugs. Currently it only make the TS maintainters' life harder, when the new version with breaking change landed, it will affect all TS users and millions of lines of code. |
Yup, we just invent a new weird JS "pattern" ( |
ESLint can't save us for cross-modules case. TS may help. But even TS can not save us in define/set case, this is why TS is forced to change d.ts to emit getter/setter instead of prop. Modifying d.ts will be a very very very very big breaking change for the whole TS ecosystem. |
We in TC39 are aware of this mismatch, and we discussed it extensively since 2016. There were various proposals made to smooth over this issue, but ultimately, the decision made (supported by the TypeScript representative at the time) was that it's more important for field declarations to have simple, reliable, consistent semantics than that they match TS semantics for this case. |
@littledan If TS semantics were the only consideration of note in favor of In the end, either this should be reconsidered, better explained, or TC39 needs to stop touting constructor elimination as a feature of this proposal. The simple fact is that this proposal leads to different results than using the constructor approach. That's regardless of TS semantics, and yet is in favor of it all the same. This is another case where we're constantly told what:
when the best way to help everyone accept is to explain "Why?". Let me help a little with a direct question: Why is it more important for field declarations to have "simple, reliable, consistent semantics" despite the numerous issues this brings to well known, widely used programming paradigms in ES? |
I just wanted to note (a little late) that I think we're getting off-topic here. It was established earlier in this thread that the issue in the OP would still occur using either Set or Define. |
Off topic for the initial post? Yes. However, the original poster changed the direction of the conversation in this thread himself: #242 (comment). Unless I missed something, everything beyond that point has been inline with that new conversational direction. |
@rdking This is true, with the caveat that the direction change was on the basis that using |
@brianmhunt Yes, as I say above, this has nothing to do with Define vs Set. The relevant difference between TS and JS here is that TS treats the |
Just popping in here to point out that the The funny part is, it happens to work with the actual |
As a matter of interest for future readers, it seems one can work around this with Babel & Typescript by changing the order of the plugins to put Typescript ahead of the class-properties i.e. in the Babel config:
The |
I think it would also work to just use the plugins: [
["@babel/plugin-proposal-class-properties", { loose: true }]
] |
As an aside, there's an open issue about adding the ...although maybe the better solution would be to make it part of |
It looks like @ljharb left a comment but then deleted it, so I won't reply to that, but I did want to share my findings from trying out some things with the latest production release of First of all, the example in the OP doesn't compile with strict mode enabled. If you disable class A {
x: string
} transpiles to: "use strict";
var A = /** @class */ (function () {
function A() {
}
return A;
}()); TypeScript also still transpiles using Set, not Define: class Parent {
set x(value: number) {
console.log(value)
}
}
class Sub extends Parent {
x = 1
} Here's how it transpiles the var Sub = /** @class */ (function (_super) {
__extends(Sub, _super);
function Sub() {
var _this = _super !== null && _super.apply(this, arguments) || this;
_this.x = 1;
return _this;
}
return Sub;
}(Parent)); So the semantics are definitely different than the semantics of this proposal. It's a bit off-topic, but I also wanted to note that the latest version of create-react-app sets |
I deleted my comment, because TS doesn’t yet provide a way to generate compliant output. Once they do, that’s what should be the default. |
Sorry for the multiple posts in a row, but I just realized that these differences could cause some significant confusion for those who were previously targeting ES5 but now would like to compile to ES6+, which will become increasingly common as people drop IE support (which is already happening of course). So it's good to hear that TS will be changing their (hopefully default) output to match the spec. |
This behavior has caused us a lot of headaches, too. As a developer with most of my OOP background in Python and PHP, I personally found this behavior unintuitive and misleading. I've seen numerous support questions in the Babel issue tracker regarding this behavior or its side effects, probably because of the likewise mindsets out there. I also second that this issue is not related to // @babel/plugin-proposal-class-properties with either loose: true or false.
//
// Order of execution:
//
// 1. Parent property initializer
// 2. Parent constructor
// 3. Child property initializer
class Super {
prop = 'Parent property initializer'
constructor () {
this.prop = 'Parent constructor'
}
}
class Derived extends Super {
prop = 'Child property initializer'
}
instance = new Derived();
console.log(`Final value => ${instance.prop}`)
// Outputs: "=> Child property initializer" While in Python and PHP the output would have been "Parent class constructor" instead. Checkout these repls: As far as I can see, the To put it into perspective for myself, I did a bit of experiment with other languages as well; I guess I gotta broaden my horizon! |
@sepehr what you're describing is true prior to class fields; it seems like an inherent difference in how inheritance works in JS and some other languages. Parent construction code is finished before child constructor code gets any access to the instance. |
The behavior of Java and C++ is not because the construction/initialization order, but the semantic: the field of Super and Derived are two fields (storage) at all (even they use same name) |
It's only one property in JS also (except for private fields, but we're discussing public fields...private fields aren't properties at all). |
@sepehr what you're describing isn't that difficult to implement in ES, but don't expect that the language supports it with syntax. The trick is to control the order of instances and prototypes while using accessors to keep data on the appropriate object. As an example, for an instance who's class was defined as roughly C extends B extends A, the instance structure would look like this: let instance = { //C instance
//C instance property accessors
__proto__: { //B Instance
//B instance property accessors
__proto__: { //A instance
//A instance property accessors
__proto__: { //C prototype
__proto__: { //B prototype
__proto__: { //A prototype
__proto__: Object.prototype
}
}
}
}
}
}; If classes were structured like this, you could get what you wanted, but as you might guess, there are several problems with this design, not the least of which is the need to defeat copy-on-write semantics for properties behind a prototype interface by using accessors. That means the actual, per-class instance property storage is somewhere else. Not very memory efficient, or fast. Another problem is that for classes extending native classes, the instance of the native object needs to be the top object. That alone interferes with inheritance, as any own property of the native object will always override all properties of subclasses. |
It is a common pattern to have a parent class that sets the properties of children, but the current TC39 spec introduces a problem with this pattern: children properties are set to
undefined
.Just one illustrative example (in Typescript, since the types aid the illustration):
The current spec 2.7 DefineField(receiver, fieldRecord) at
2.7.6
mandates that propertiesa
andb
be assigned at the construction ofChild
the valueundefined
.Based on my own experience, this behaviour obscures bugs that can be subtle and difficult to debug. If introduced into e.g. Typescript this would have the potential to break a lot of code.
This problem would be avoided if a class does not assign
undefined
if the parent has already assigned a value (i.e. the property exists). I feel quite strongly that this is the behaviour most developers expect.If one wishes to obscure properties from parents, it could be explicitly done with a new keyword such as
protected
orown
i.e.I noted that this is the behaviour honoured by
@babel/plugin-proposal-class-properties
, and that it's already been identified there as an issue babel/babel#8280 (closed on the basis that it's in compliance with this spec).The text was updated successfully, but these errors were encountered: