Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle breaking change in class property runtime behavior #27644

Closed
7 of 9 tasks
RyanCavanaugh opened this issue Oct 9, 2018 · 53 comments
Closed
7 of 9 tasks

Handle breaking change in class property runtime behavior #27644

RyanCavanaugh opened this issue Oct 9, 2018 · 53 comments
Assignees
Labels
Breaking Change Would introduce errors in existing code Bug A bug in TypeScript ES Next New featurers for ECMAScript (a.k.a. ESNext)

Comments

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Oct 9, 2018

From #12212 (comment) @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

TypeScript 3.7

  • Always emit accessors in .d.ts files #33470: emit get/set accessors in declaration files
  • Disallow property/accessor overrides #33401: accessors may not override properties and vice versa.
  • Disallow uninitialised property overrides #33423: uninitialised properties may not override properties.
    • Introduce new syntax, class C extends B { declare x: number }
    • Introduce a codefix that inserts declare where needed.
    • This declaration is erased, like the original declaration in old versions of TS
  • Add useDefineForClassFields flag for Set -> Define property declaration #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 to false
    • Declaration emit is the same regardless of the flag's value.

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.
@RyanCavanaugh RyanCavanaugh added Bug A bug in TypeScript Breaking Change Would introduce errors in existing code ES Next New featurers for ECMAScript (a.k.a. ESNext) labels Oct 9, 2018
@RyanCavanaugh RyanCavanaugh added this to the Future milestone Oct 9, 2018
@littledan
Copy link

littledan commented Oct 10, 2018

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.

@RyanCavanaugh
Copy link
Member Author

@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 undefined seems like an odd bit of sugar to have when it creates a footgun for derived classes as a trade-off.

@bakkot
Copy link
Contributor

bakkot commented Oct 10, 2018

@RyanCavanaugh:

class Foo {
  val;
  constructor({ val }) {
    this.val = val;
  }
}

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.

@RyanCavanaugh
Copy link
Member Author

RyanCavanaugh commented Oct 10, 2018

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

@ljharb
Copy link
Contributor

ljharb commented Oct 10, 2018

fwiw static enforcement of that pattern is trivially possible with eslint, with or without an explicit initializer.

@bakkot
Copy link
Contributor

bakkot commented Oct 10, 2018

No, it's meaningfully different: because it uses [[DefineOwnProperty]] semantics, it guarantees the property is an own data property, rather than risking triggering setters on a superclass.

You can of course put a Object.defineProperty in the constuctor, but that's... not great.


I recall we discussed the possibility of requiring initializers at some point, though I can't dig that up just now.

@RyanCavanaugh
Copy link
Member Author

rather than risking triggering setters on a superclass

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) ?

@littledan
Copy link

We did actually discuss it to death in TC39... the classic Set vs Define debate (see summary by @bakkot; even though we've already discussed it a bunch, I'm really happy to hear your thoughts on the issue). Compatibility and constructor correspondence were raised, but the committee was ultimately convinced to take the Define and implicit = undefined path, partly due to evidence from @jeffmo that these semantics were not very difficult to migrate to in Facebook . The hope was that the Define semantics would actually be easier to understand locally, as you don't have to think about whether the superclass has a setter since you'll always get that property defined.

@jeffmo
Copy link

jeffmo commented Oct 11, 2018

It's true that when I looked at this before it wouldn't have been difficult to migrate things at Facebook (IIRC very few places would have had issues in the first place), but it's also worth noting that usage of setters is pretty rare in JS written at FB (as a matter of both tooling support and style).

Where they are used I was able to flag them for manual inspection -- but it may also be possible to do something more heuristically smart than just manual inspection for codebases that use them more frequently.

@Jessidhia
Copy link

This has been a problem with babel's typescript support as well, because we need to know how should we treat field type annotations without an initialization.

Without --strictPropertyInitialization, how should this code be stripped of annotations:

interface Bar {}

class Foo {
  bar: Bar = {}
}

class Baz extends Foo {
  bar: Bar
}

If we only strip the : Bar from the second class, we're actually overriding its value with undefined in babel (as long as it's not being transformed with spec: false). We'd have to strip the entire of bar: Bar to not break at runtime but that'd be a possible break if bar is not actually an inherited field ('bar' in new Baz() would fail for example).

@wycats
Copy link

wycats commented Oct 11, 2018

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 declare to mark a field as "type only"

class N {
  node: Node
}

class E {
  declare node: Element
}

This would have the same behavior as today's redeclaration, while the field syntax would have the standards-compliant behavior.

@RyanCavanaugh
Copy link
Member Author

RyanCavanaugh commented Oct 11, 2018

We'd have to strip the entire of bar: Bar to not break at runtime

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.

use declare to mark a field as "type only"

I like this quite a bit

as you don't have to think about whether the superclass has a setter since you'll always get that property defined.

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.

class Base {
  // This is a nice property that normalizes null/undef to 0!
  get value { return this._value }
  set value(v) { this._value = v ? v : 0; }

  func(x) {
    this.value = x;
    // Can't happen because the setter fixes it
    Debug.assert(this.value !== undefined);
    console.log(this.value + 10);
  }
}

class Derived extends Base {
  // FYI guys, value comes from the Base class, no worries
  value;
}

const d = new Derived();
d.func(undefined); // oops!

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 a this.p = e assignment 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.

@mheiber
Copy link
Contributor

mheiber commented Oct 18, 2018

FYI, we have some work in progress to move class fields transformation from ts.ts to esnext.ts: bloomberg#10.

@Jamesernator
Copy link

@RyanCavanaugh I think the main confusion for me that class fields don't trigger setters is mostly because of the use of the = token rather than some alternative.

Personally I would've preferred foo := 12 or some other differing syntax to say, hey this is a declaration not a property assignment. But it is in popular use already thanks to Babel, and Chrome is planning on shipping it soon so we're probably stuck with it.

Getting past the use of = I think it makes sense as it's a lot like an object literal e.g.:

const base = {
  set someValue(value) {
    console.log("triggered a setter!")
  }
}

// No setter triggered
const derived = {
  __proto__: base,
  someValue: 12
}

// No setter triggered
derived.someValue = 12

const derived2 = { __proto__: base }

// setter triggered
derived2.someValue = 12
class Derived extends Base {
  // analagous to someValue: 12 in an object literal
  someValue = 12
}

@RyanCavanaugh
Copy link
Member Author

RyanCavanaugh commented Jan 25, 2019

Note: nothing from this list has happened yet


Proposal

  • TypeScript 3.4
    • No changes to .js emit yet
    • It is now legal to write declare class C { get p(): number; }
      • Same semantics as p: number;, except for caveats below
    • Introduce new flag, --legacyClassFieldEmit. When specified:
      • Initialized fields in class bodies are always emitted as constructor assignments (even when targeting ESNext), and uninitialized fields in class bodies are always erased
      • Does not affect declaration emit
    • Introduce new syntax, class A { declare x: number; }
      • This form is always erased
      • These fields are subject to strictClassInitialization checking; declare x!: number is legal.
    • If --legacyClassFieldEmit is not specified:
      • It is an error to declare a property (initialized or not) if the base class defines that property as a getter/setter pair
      • TBD: property as a getter/setter pair if the base class defines that property as a non-getter/setter pair
  • TypeScript 3.5
    • No changes to .js emit yet
    • Declaration emit now emits getter/setter pairs in classes
  • TypeScript 3.6
    • Default downlevel emit changes to defineProperty semantics

@littledan
Copy link

littledan commented Jan 25, 2019

This design sounds good to me.

What do you mean by, "Declaration emit now emits getter/setter pairs in classes"? A define-style field in a superclass would shadow a getter/setter pair.

@RyanCavanaugh
Copy link
Member Author

What do you mean by, "Declaration emit now emits getter/setter pairs in classes"?

Today if you build this class

class A {
  get p() { return 0; }
  set p(n) { }
}

the declaration emit is

class A {
  p: number;
}

which would prevent us from detecting the derived class accidently clobbering the field.

@littledan
Copy link

Thanks for explaining!

@hax
Copy link

hax commented Sep 3, 2019

I wish people on the TS team had argued against it more earlier in the process

The best time to fix a issue was early stage. The second best time is now.

@sandersn
Copy link
Member

sandersn commented Oct 4, 2019

After we noticed problems with backward compatibility of .d.ts files in Azure SDK, we decided to change .d.ts emit when --useDefineForClassFields is off. Instead of emitting accessors, which only versions of Typescript will not parse in .d.ts, the compiler will emit a property with a leading comment. So this class:

class B {
  get p() { }
  set p(value) { }
  get q() { }
  set r(value) { }
}

Would emit

declare class B {
  /** @get @set */ p: any
  /** @get */ q: any
  /** @set */ r: any
}

Typescript 3.7 and above will look for these comments on property declarations and treat them as accessor declarations instead.

@trotyl
Copy link

trotyl commented Oct 7, 2019

I still have concerns about the [[Define]] semantics, especially this case:

class Base {
  x = 1;
}

class Derived extends Base {
  get x() { return 2; }
  set x(value) { console.log(`x was set to ${value}`); }
}

const obj = new Derived(); // nothing printed
console.log(obj.x); // 1

The x field on Base replaces the x accessor from Derived, which breaks inheritance and may be confusing for users.

@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:

class Base {
  get brand(): string {
    throw new Error(`Brand must be defined`)
  }
}

class Derived extends Base {
  brand = 'derived'
}

const d = new Derived()
console.log(d.brand)

The above works well with [[Define]], but throwing for [[Set]]. You may argue that TypeScript has abstract modifier to better handle this, but abstract cannot be combined with static, for example:

class Base {
  static get brand(): string {
    throw new Error(`Brand must be defined`)
  }
}

class Derived extends Base {
  static brand = 'derived'
}

console.log(Derived.brand)

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.

@Jessidhia
Copy link

That was my point with #27644 (comment)

@mbrowne
Copy link

mbrowne commented Oct 7, 2019

@trotyl I disagree. When comparing options, we should consider the frequency and severity of the issues, and I think it's a real stretch to say both options are equally good/bad. The use cases I've seen where [[Set]] is problematic are mostly issues of semantic purity or fairly minor issues that would occur pretty rarely. Your example is the first I've seen that I would agree is a pretty significant footgun when using [[Set]]. But the foot-guns with [[Define]] are generally more significant and seem more likely to occur, at least based on what I've seen in online discussions about this issue.

So that's my two cents, but I'm not sure why we're still discussing this at all—the ship has sailed. I agree with those who say that stage 3 shouldn't completely close the window to further changes (e.g. changing the syntax to := instead of =), but I think the bar for justifying such changes should be very high—for example, if the semantics of the current proposal were causing great confusion to thousands of developers on a daily basis after this feature shipped in browsers. Since the Set/Define issue only affects this edge case of inherited fields and setters, I just don't see that happening. I think at this point, far more confusion would be caused by changing the default semantics of a feature that's already shipped than the relatively rare instances where the Set/Define distinction actually matters.

@trotyl
Copy link

trotyl commented Oct 8, 2019

@mbrowne

When comparing options, we should consider the frequency and severity of the issues, and I think it's a real stretch to say both options are equally good/bad. The use cases I've seen where [[Set]] is problematic are mostly issues of semantic purity or fairly minor issues that would occur pretty rarely. Your example is the first I've seen that I would agree is a pretty significant footgun when using [[Set]]. But the foot-guns with [[Define]] are generally more significant and seem more likely to occur, at least based on what I've seen in online discussions about this issue.

I see your points, but have got different conclusions.

Almost every "problematic" examples with [[Define]] I saw are violating OO practices, more specifically, overriding a base class member without knowing it intended to be virtual/abstract.

Even the example provided by @rbuckton, from a reviewer perspective, I'd say it's intended to break the application. Even with the x in Base class being an accessor property, overriding it without calling super getter/setter would always result to unexpected behavior, as the base accessor may contain critical logic for base class to work. Doing this kind of overriding one should at least add following comment to base class:

class Base {
  // IMPORTANT NOTE!!! 
  // DO NOT EVER CHANGE THIS TO ACCESSOR, IT WON'T GET CALLED
  x = 1;
}

class Derived extends Base {
  get x() { return 2; }
  set x(value) { console.log(`x was set to ${value}`); }
}

I don't believe this is how class inheritance should be, let alone the base class may not be in the same project.

Regarding controlling of override ability, OO language can be divided into two categories, explicit-virtual and explicit final, the latter in practice has been considered a design failure, where in any Java code style you should always write the final manually, and newly created JVM languages like Kotlin has go to the opposite design.

From the lengthy history of OO language, overriding a base class member without knowing it can be overridden is not something feasible, the whole application could break one day or another.

Unfortunately JavaScript is even worse than explicit-final languages, which cannot prevent overriding at all (in common declarative class code). If possible, I'd definitely like JavaScript class member to have explicit-virtual semantics, or at least runtime detection like tc39/proposal-class-fields#176 (comment).

@shrinktofit
Copy link

What about decorators? Will the property descriptor is passed to decorator if useDefineForClassFields: true?

@sandersn
Copy link
Member

@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

class B {
  x = 1
}
class D extends B {
  readonly x = 2
}

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.

@sandersn
Copy link
Member

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Breaking Change Would introduce errors in existing code Bug A bug in TypeScript ES Next New featurers for ECMAScript (a.k.a. ESNext)
Projects
None yet
Development

No branches or pull requests