TypeScript: Handle breaking change in class property runtime behavior
From https://github.com/Microsoft/TypeScript/issues/12212#issuecomment-428342448 @joeywatts:
class A {
property = 'some string';
}
class B extends A {
property;
}
const instanceB = new B();
// "some string" or "undefined" ?
console.log(instanceB.property);
It appears from the current definition at https://github.com/tc39/proposal-class-fields that this code will print undefined.
If the proposal advances further with these semantics, we’ll need to figure out a mitigation strategy, preferably sooner rather than later.
One option is to make the code as written a type error for ~2 versions and force people to write an explicit = undefined initializer, then remove the error.
Edit [@sandersn]:
Here is the current plan, together with what has happened so far.
TypeScript 3.6
- #32787: allow accessors like
declare class C { get p(): number; }(andsetas well) - No changes to .js emit yet
- No changes to .d.ts emit yet
TypeScript 3.7
- #33470: emit get/set accessors in declaration files
- #33401: accessors may not override properties and vice versa.
- #33423: uninitialised properties may not override properties.
- Introduce new syntax,
class C extends B { declare x: number } - Introduce a codefix that inserts
declarewhere needed. - This declaration is erased, like the original declaration in old versions of TS
- Introduce new syntax,
- #33509 Introduce a new flag,
useDefineForClassFields.- When
"useDefineForClassFields": false:- Initialized fields in class bodies are emitted as constructor assignments (even when targeting ESNext).
- Uninitialized fields in class bodies are erased.
- The preceding errors are silenced.
- When
"useDefineForClassFields": true:- Fields in class bodies are emitted as-is for ESNext.
- Fields in class bodies are emitted as Object.defineProperty assignments in the constructor otherwise.
- In 3.7,
"useDefineForClassFields"defaults tofalse - Declaration emit is the same regardless of the flag’s value.
- When
TypeScript 3.8
(or, the first version after class fields reaches Stage 4)
-
"useDefineForClassFields"defaults to true when targetting ESNext. - issue new errors as suggestions with codefixes, even when
"useDefineForClassFields": false.
About this issue
- Original URL
- State: closed
- Created 6 years ago
- Reactions: 12
- Comments: 53 (22 by maintainers)
I don’t think there’s much we can do about the strict breakage, but I’m concerned about the fact that this makes it impossible to re-declare existing fields on subclasses.
One suggestion: use
declareto mark a field as “type only”This would have the same behavior as today’s redeclaration, while the field syntax would have the standards-compliant behavior.
Stage 3: Indicate that further refinement will require feedback from implementations and users
¯\_(ツ)_/¯
@trotyl @mbrowne The corpus I inspected, our internal test repos + the ones at
~/Typescript/tests/cases/user, shows that people rarely override properties with accessors and vice versa. I tested the code with--useDefineForClassFields, which disallows property/accessor and access/property overrides.In fact, failures only happened in older code: there was one example of a property-override-accessor in Angular 2’s examples. I believe it was intentional, and would only work with [[Set]]. There were 7 examples of accessor-override-property, all of which were trying to make the derived property readonly by only providing
get, which would crash with [[Set]], so I think those accessors were never used.For modern TS, they would instead write
Google and Bloomberg also checked their code bases and found no tricky failures. It’s safe to conclude that [[Set]] vs [[Define]] is not practically important.
This was raised in the office and I strongly opposed it - TS has never used a type annotation to change runtime behavior and I would not want to start now.
I like this quite a bit
Every time I hear this it sounds like an extremely solid argument against the behavior as proposed. If your base class has a setter, it almost certainly wrote it as a setter for a reason, and the base class is going to get super confused when it doesn’t see its own setter behavior firing. e.g.
You’re asking the derived class author to understand whether or not the base class property is a setter in order to understand whether or not it’s safe to use a property declaration in the derived class, whereas previously this was a distinction that was rather hard to notice without a direct call to
defineProperty. People will mess this up, and important setter side effects or validations are going to get unintentionally clobbered by people who think it’s safe to move athis.p = eassignment in the derived constructor body into the class body because it looks neater.If your derived class is intentionally stomping on its base class setters, I question whether it’s even plausible that it’s a correctly substitutable instance of its base class. Breaking your base class should be difficult, not easy.
The best time to fix a issue was early stage. The second best time is now.
What’s an example of a refinement that wouldn’t break the web? Why does Stage 3 exist at all? Should we just move things to Stage 4 as soon as Chrome puts it out there unflagged?
The TypeScript team can proceed however they like of course, but IMO it would be worth going ahead and beginning work on updating the semantics of TS class properties to match the new ES spec. The PR could be left open since TC39 could still receive new and significant feedback as class fields are used in the real world. But I think the odds are extremely low that the real-world issues with the Object.defineProperty behavior would be so significant and common that they would justify changing the semantics at this point. I’m concerned about compatibility issues between JS and TS here, especially now that many people are compiling TS with Babel.
BTW, as someone who’s not a fan of the Object.defineProperty semantics, I wish people on the TS team had argued against it more earlier in the process, especially those who are on the TC39 committee (although perhaps there were mixed opinions among the TS team). But there were two valid sides to that debate, and at some point we have to accept the spec and move on. Otherwise the differences between TS and JS will probably cause far more issues than would ever have been directly caused by the defineProperty semantics in the first place.
@bigopon Yes, these are the semantics that TC39 chose deliberately in 2016 in collaboration with the TypeScript team.
I’m sure you guys have discussed this to death already, but having a body-level declaration be subtly different from a constructor assignment (i.e. it only matters when there’s a superclass setter) is only going to increase developer confusion.
Now when I look at code with an uninitialized declaration, I have to nervously wonder whether it’s there for documentation purposes, or to overwrite a property slot because of the risk of triggering a base class setter (isn’t not triggering it the risk? Why is the base class setter there in the first place) ?
Why is that kind of construct even desirable without static (or even dynamic!) enforcement? It’s just a comment written in a different syntax - destined to be out of date and out of sync with what actually happens in the constructor
@littledan this is a bit of a compat nightmare for us and it’d be great to understand why initializers are optional in the current proposal. Why would someone write an initializer-free non-private declaration in the first place? Staking out an own property with the implicit value
undefinedseems like an odd bit of sugar to have when it creates a footgun for derived classes as a trade-off.Since this targets 3.7.1, I’m going to close this bug. When class fields reaches stage 4, I’ll do the final two steps.
I still have concerns about the
[[Define]]semantics, especially this case:The
xfield onBasereplaces thexaccessor fromDerived, which breaks inheritance and may be confusing for users.Given the time remaining for 3.6, I’m moving this to 3.7.
Please, many of us beg, don’t make
[[Define]]the default.@trusktr just another example that i just ran into, related to [[Define]], and maybe someone already mentioned it:
It will be a massive mess regardless TS/JS
hrefis defined on the instance:What it really is expected to be: trigger the
setter@robpalme Yes this is the “fix” if follow the [[Define]] semantic. Unfortunately, such fix is not very obvious to most JS/TS programmers, and many see your “correct code” and want to refactor it to the wrong form because they are educated by React. 🙃
There are also many cases, you have no way to “fix”. For example: Base class originally use traditional imperative form, subclass override it to accessors. Then one day the author of base class want to adopt class fields because it’s a new feature and should be wonderful! Remember the author of subclass can not “fix” the decision of base class.
Will this feature be disabled by default, so that
[[Set]]semantics are default? It’s going to introduce red squiggly lines into projects if the class fields suddenly are not allowed due to super classes having accessors. 3.6 isn’t amajorversion bump, you know.People will accidentally shoot their legs off with
[[Define]]gun shells. 😃For example, I made an oil painting of a gruesome premonition:
It was a bloody mess.
@RyanCavanaugh:
I expect to see a lot of code like the above. It’s really nice to be able to see the shape of the class without having to look in the constructor, but sometimes the value which goes into the field depends on arguments to the constructor.
That was my point with https://github.com/microsoft/TypeScript/issues/27644#issuecomment-518543395
@rbuckton Using
[[Set]]won’t really fix inheritance, but only make the breaking happens to some other people. Say that I need to use throwing getter as abstract property, like:The above works well with
[[Define]], but throwing for[[Set]]. You may argue that TypeScript hasabstractmodifier to better handle this, butabstractcannot be combined withstatic, for example:This issue already happens in TypeScript today and break my use case. I’m not going to say
[[Define]]is better than[[Set]], in fact, both of them cause footguns and neither is better than another.Fair enough.
@ljharb FWIW I don’t think it’s useful to suggest no changes are possible on a stage 3 proposal until the feedback is collected and presented. It may be practically true, but it’s better to be receptive to user/implementer feedback during stage 3 until TC39 has explored the options before them in light of the feedback given.
Mind you I still think fields should be declared with
:=rather than=to make it clearclass Point { x := 10 }is more likeo = { x: 10 }rather thano.x = 10, but I think it’s probably too late to change this.I’ve generally found that depending on subclass setters/getters just tends to make the base class much harder to refactor so
// nothing printeddoesn’t really bother me.The other case of
console.log(obj.x); // 1is a bit more concerning, but I don’t really see any way around these sort’ve problems as it’s basically the same as this sort’ve problem (that will happen regardless of[[Set]]vs[[Define]]semantics):Please don’t break people’s code when they update to TypeScript 3.6.
At the very least, having an option like
classFieldDefine, even if defaulting totrue, would be awesome. I would certainly set it tofalse.Something that @littledan brought up last night: the
--experimentalDecoratorsflag currently expects set semantics, not define semantics. We’ll likely have to maintain the old emit with this flag turned on.P.S.: Keywords for me in the future: set define class fields instance fields properties defineproperty defineproperties
I am happy to see TS looking into this important compatibility issue. This plan sounds good to me.
Another potential incompatibility is that TC39’s fields are defined with Object.defineProperty, not =, so setters defined in a superclass will not run.