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

Add useDefineForClassFields flag for Set -> Define property declaration #33509

Merged
merged 57 commits into from Sep 26, 2019

Conversation

@sandersn
Copy link
Member

@sandersn sandersn commented Sep 19, 2019

This PR is part of the migration from [[Set]] semantics to [[Define]] semantics. #27644 tracks the complete migration. This PR includes steps 1-4 for Typescript 3.7:

  • #33470: emit get/set accessors in declaration files. This means that 3.7-emitted d.ts files will be incompatible with 3.5 and below.
  • #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 declare where needed.
    • This declaration is erased, like the original declaration in old versions of TS
  • #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.
  • Bonus: The error "properties/accessors aren't allowed to override methods" was not reported from 3.0 onward. This PR also fixes that error.

Tasks from the design meeting:

  • Abstract properties with initialisers generate code, so should generate the error after all.
  • ES3 should set+require useDefineForClassFields=false
  • useDefineForClassFields=true should also use defineProperty for method emit so we can set enumerability correctly.
  • change flag name to useDefineForClassFields and flip polarity from legacyClassFields

Accessors may not override properties and vice versa

Briefly, properties can now only override properties, and accessors can only override accessors. The tests come from the user-submitted horrorshow parade in #27644. Thanks to all who contributed there.

Exceptions

  1. If the base property is abstract or in an interface, there is no error unless the abstract base property has an initialiser.
  2. If the symbol has both Property and Accessor set, there is no error. (I'm not sure when this can happen, although @weswigham says he can produce a Javascript example pretty easily.)

Motivation

Accessors that override properties have always been error-prone with [[Set]] semantics, so these new errors are useful with [[Set]] or [[Define]] semantics. For example, base-class properties call derived accessors, which may be unexpected. The code below prints 2, the derived value, but it also prints set 1. That's because the base p = 1 calls derived accessor set p.

class B {
    p = 1
}
class D extends B {
    _p = 2
    get p() { return this._p }
    set p(value) { console.log('set', value); this._p = value }
}
var d = new D()
console.log(d.p)

In fact, if the derived class attempts to make p readonly by leaving off set p, the code crashes with "Cannot set property p of #<D> which has only a getter." This should always have been an error. However, because we haven’t given errors before, and because fixing the errors is likely to be difficult, we probably would not have added them otherwise, or added them in so many cases. Here are some examples of code that will be tricky to change:

class CleverBase {
  get p() { 
    // caching, etc.
  }
  set p() {
  }
}
class Simple extends CleverBase {
  p = 'just a value'
}

CleverBase is a framework class that intends to cache or transform the value provided by Simple. Simple is written by a framework user who just knows that they need to extend CleverBase and provide some configuration properties. This pattern no longer works with [[Define]] semantics. I believe the Angular 2 failure I found below is similar to this case.

class LegacyBase {
  p = 1
}
class SmartDerived extends LegacyBase {
  get() {
    // clever work on get
  }
  set(value) {
    // additional work to skip initial set from the base
    // clever work on set
  }
}

This code is the same as the first example — accessors override a property — but the intent is different: to ignore the base's property. With [[Set]] semantics, it's possible to work around the initial set from the base, but with [[Define]] semantics, the base property will override the derived accessors. Sometimes a derived accessor can be replaced with a property, but often the accessor needs to run additional code to work around base class limitations. In this case, the fix is not simple. I saw this in a couple of Microsoft apps, and in one place it had a comment "has to be a getter so overriding the base class works correctly".

How to fix the errors

Property overrides accessor

class CleverBase {
  _p: unknown
  get p() {
    return _p
  }
  set p(value) {
    // cache or transform or register or ...
    _p = value
  }
}
class SimpleUser extends CleverBase {
  // base setter runs, caching happens
  p = "just fill in some property values"
}

SimpleUser will have an error on the property declaration p. The fix is to move it into the constructor as an assignment:

class SimpleUser extends CleverBase {
  constructor() {
    // base setter runs, caching happens
    this.p = "just fill in some property values"
  }
}

Since CleverBase declares p, there's no need for SimpleUser to do so.

Accessor overrides property

class LegacyBase {
  p = 1
}
class SmartDerived extends LegacyBase {
  get() p {
    // clever work on get
  }
  set(value) p {
    // additional work to skip initial set from the base
    // clever work on set
  }
}

SmartDerived will have an error on the get/set declarations for p. The fix is to move them into the constructor as an Object.defineProperty:

class SmarterDerived extends LegacyBase {
  constructor() {
    Object.defineProperty(this, "p", {
      get() {
        // clever work on get
      },
      set(value) {
        // clever work on set
      }
    })
  }
}

This doesn't have exactly the same semantics; the base never calls the derived setter for p. If the original setter does have skip-initial-set code to work around the current weird Typescript semantics, that code will need to be removed. However, all the real examples I saw were just using accessors to make the property readonly, so they could instead use a readonly property.

Uninitialised property declarations may not override properties

Briefly, uninitialised property declarations that must now use new syntax when the property overrides a property in a base class. That's because when Typescript supports [[Define]] semantics, previously uninitialised property declarations will instead have an initial value of undefined. This will be a major breaking change:

class B {
  p: number
}
class C extends B {
  p: 256 | 1000 // use whatever value you need here; it
}

Previously this emitted

class B {}
class C extends B {}

When Typescript supports [[Define]] semantics, it will instead emit

class B {
  p
}
class C extends B {
  p
}

which will give both B and C and property p with the value undefined. (This is an error today with strictNullChecks: true.)

The new syntax will cause Typescript to emit the original JS output:

class B {
  p: number
}
class C extends B {
  declare p: 256 | 1000
}

This PR adds an error prompting the author to add the new syntax in order to avoid the breaking change. As you can see from the baselines, this error is pretty common. From my first run of our extended test suites, it looks pretty common in real code as well.

Note that the compiler can only check constructors for initialisation when strictNullChecks: true. I chose to be conservative and always issue the error for strictNullChecks: false.

Other ways to fix the error

  1. Make the overriding property abstract.
  2. Make the base property abstract.
  3. Give the overriding property an initialiser.
  4. Initialise the overriding property in the constructor -- but this only works with "strictNullChecks": true.

Notes

  1. Adding a postfix-! will not fix the error; ! is always erased, so, in the last example, p!: 256 | 1000 would still result in p === undefined for [[Define]] semantics.
  2. You can add declare to any uninitialised property, even if it's not an override. I previously had a check that prevented this, but I think an easy upgrade is valuable (you can almost write a regex for it).
sandersn added 28 commits Sep 12, 2019
Unless the base property or accessor is abstract
This causes quite a few test breaks. We'll probably want to revert many
of them by switching to the upcoming `declare x: number` syntax.
1. Don't error when overriding properties from interfaces.
2. Fix error when overriding methods with other things. This had no
tests so I assume that the code was always dead and never worked.
Will update after checking out other branch for a minute
Need to test properties initialised in constructor
And simplify redundant parts of check.
@sandersn
Copy link
Member Author

@sandersn sandersn commented Sep 19, 2019

@typescript-bot pack this

@sandersn sandersn merged commit 500a0df into master Sep 26, 2019
5 checks passed
@sandersn
Copy link
Member Author

@sandersn sandersn commented Sep 26, 2019

I merged this so it gets an overnight run before the beta snap. @rbuckton if you have more comments about emit, please let me know so I can address them in the beta.

@AlCalzone
Copy link

@AlCalzone AlCalzone commented Oct 2, 2019

I haven't seen any mention of this in the beta release notes. Is that intentional?

@sandersn
Copy link
Member Author

@sandersn sandersn commented Oct 3, 2019

Nope! Leading up to the beta release I was too busy to proof the notes and it looks like @DanielRosenwasser just missed it. I'll make sure it gets into the notes for the full release.

@DanielRosenwasser
Copy link
Member

@DanielRosenwasser DanielRosenwasser commented Oct 4, 2019

Apologies! It will be noted in the RC/Final Release.

sandersn added a commit that referenced this issue Oct 15, 2019
Originally removed incorrectly along with method-override-property error
in #24343, then both were restored in #33509. Only
method-override-property should be an error, since it doesn't actually
work at runtime.
sandersn added a commit that referenced this issue Oct 15, 2019
Originally removed incorrectly along with method-override-property error
in #24343, then both were restored in #33509. Only
method-override-property should be an error, since it doesn't actually
work at runtime.
@vkrol
Copy link

@vkrol vkrol commented Oct 24, 2019

@DanielRosenwasser

It will be noted in the RC/Final Release.

There is nothing about it in the RC release notes. Will it be mentioned in the final release notes or it will be postponed to the next release?

@agrozyme
Copy link

@agrozyme agrozyme commented Dec 6, 2019

import assert = require('assert');

describe('BaseClass', () => {
  interface YI {}

  abstract class X extends Base implements YI {
    xa!: string;
    xf!: string;
    xfr!: string;

    protected ef: string = 'ex';
    protected readonly efr: string = 'ex';

    constructor(...items: any[]) {
      this.setup(...items);
    }

    setup(...items: any[]): void {}

    protected get ea() {
      return 'ex';
    }

    static z() {
      return 'X';
    }

    type() {
      return X;
    }
  }

  class Y extends X {
    protected ef: string = 'ey';
    protected readonly efr: string = 'ey';

    protected get ea() {
      return 'ey';
    }

    static z() {
      return 'Y';
    }

    static zz() {
      return super.z();
    }

    setup(...items: any[]): void {
      this.xf = 'ey' === this.ef ? 'TRUE' : 'FALSE';
      this.xfr = 'ey' === this.efr ? 'TRUE' : 'FALSE';
      this.xa = 'ey' === this.ea ? 'TRUE' : 'FALSE';
    }

    type() {
      return Y;
    }
  }

  const y = new Y();

  it('Y', () => {
    console.dir(y);
    assert.strictEqual('FALSE', y.xf);
    assert.strictEqual('FALSE', y.xfr);
    assert.strictEqual('TRUE', y.xa);
    assert.strictEqual('Y', y.type().z());
    assert.strictEqual('X', y.type().zz());
  });
});

when useDefineForClassFields = false
result:

Y { xf: 'FALSE', xfr: 'FALSE', xa: 'TRUE', ef: 'ey', efr: 'ey' }

when useDefineForClassFields = true
result:

Y {
  xf: undefined,
  xfr: undefined,
  xa: undefined,
  ef: 'ey',
  efr: 'ey' }

@agrozyme
Copy link

@agrozyme agrozyme commented Dec 7, 2019

OK
I create a new issue: #35563
maybe it only repro in unit testing?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked issues

Successfully merging this pull request may close these issues.

None yet

8 participants