镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Inherited typing for class property initializers #10570

Description

Problem

Initializing a class member with things like { }, null, undefined, or [] has unexpected behavior.

class Base {
  favorites = ["red", "blue"];
}
class Derived extends Base {
  favorites = [];
  constructor() {
    this.favorites.push('green'); // Can't push string onto never[], wat?
  }
}
interface Settings {
  size?: number;
  color?: string;
}
class Base {
  settings: Settings = { size: 42 };
}
class Derived extends Base {
  settings = { };
  constructor() {
    if (big) this.settings = { siz: 100 }; // no error, wat?
  }
}

Solution

New rule: When a class property is initialized with exactly null, undefined, { }, or [], the type of the property is taken from the same property of the inherited type (if one exists), rather than the type of the initializer.

The inherited type is B & I1 & I2 & ... where B is the base class and I1, I2, ... are the implemented interfaces of the class.

Examples

interface Positionable {
  position: string | null;
}
class MyPos implements Positionable {
  position = null;
  setPos(x: string) {
    this.position = x;
  }
  getPos() {
    return this.position.subtr(3); // error detected
  }
}
class Base {
  items = ['one'];
}
class Derived extends Base {
  items = []; // no longer an implicit any
}
var x = new Derived();
x.items.push(10); // Error as expected

Bad Ideas We Thought Were good

image

Contextual typing plays poorly with other behavior such as unit type positions. Consider

enum E { A, B, C }
class Base {
  thing = E.A;
}
class Derived extends Base {
  thing = E.B;
  change() {
    this.thing = E.C; // Error! wat
  }
}

This turns into a big problem because the E.B expression is contextually typed by the unit-like type E.A | E.B | E.C and so acquires the specific type E.B rather than the intended type E! Daniel found this break in Azure.

/cc conspirators Daniel Rosenwasser (@DanielRosenwasser) Nathan Shively-Sanders (@sandersn)

Activity

  1. sandersn commented on Aug 26, 2016

    @sandersn
    Member

    #6118 is the first failed attempt, which has further discussion of the problems with contextual typing. (All 3 previous attempts were based on contextual typing.)

  2. DanielRosenwasser commented on Aug 28, 2016

    @DanielRosenwasser
    Member

    I'm actually okay with this idea - it reflects a lot of the same views we're taking with open/expando types.

  3. RyanCavanaugh commented on Sep 19, 2016

    @RyanCavanaugh
    MemberAuthor

    TODO: Try blindly taking the base class's type and see what happens in the RWC

  4. sandersn commented on Sep 19, 2016

    @sandersn
    Member

    This is basically what the summary of #6118 covers, except that it was for all properties, initialised or not. You intend to try with just properties that are initialised to [], {}, null, undefined, right?

  5. RyanCavanaugh commented on Sep 19, 2016

    @RyanCavanaugh
    MemberAuthor

    Nope, all properties

  6. sandersn commented on Sep 19, 2016

    @sandersn
    Member

    How is that different from this summary on #6118, then?

  7. RyanCavanaugh commented on Sep 19, 2016

    @RyanCavanaugh
    MemberAuthor

    Make the declared type of the derived property the type of the base type's property, as if you had written field: typeof super.field = expr), rather than contextually type expr with typeof super.field

  8. sandersn commented on Sep 20, 2016

    @sandersn
    Member

    I just realised I should have linked to #10610. Confusingly, my good/bad/ugly post in #6118 actually refers to an old version of that PR, which does basically what you describe, and as far as I recall, still hits some problem.

    Daniel Rosenwasser (@DanielRosenwasser), you actually RWC with #10610. Do you have notes on the problem it hit? I remember it being literal-type related, so maybe it would be different now that we have literal types everywhere.

  9. RyanCavanaugh commented on Apr 25, 2017

    @RyanCavanaugh
    MemberAuthor

    Problem Scope

    The only thing in scope is class property intializers, not method declarations.
    Contextual typing of declared method parameters is interesting but a fully separable problem.

    Problems Summary

    When a base type (either an implemented interface, or an extends'd base class) has a property with the same name, our current behavior when a derived class initialized that type is not good, leading to frequent user confusion.

    #10484, #11714, #15191: Initialize union type from string literal

    interface I {
      xOrY: 'x' | 'y'
    }
    // Error, cannot assign "string" to "'x' | 'y'""
    class C implements I {
      xOrY = 'x'
    }

    #1373, #15136: Initialize function-typed field with function expression with unannotated parameter

    class Class<T> {
      a: (b: T) => string
    }
    
    class Class2 extends Class<number> {
      a = b => 'b' // Error, Parameter 'b' implicitly has an 'any' type.
    }

    #14684: Implicit any error when initializing with empty array

    // (noImplicitAny ON, strictNullChecks OFF)
    interface I {
        obj: { x: string[] }
    }
    // Error: x
    class C implements I {
        obj = { x: [] }
    }

    Optional properties vanish

    interface HasOpts {
    	opts: {
    	  x: number;
    	  y?: number;
    	}
    }
    class Foo implements HasOpts {
    	opts = { x: 3 };
    	bar() {
    		// Error, property 'y' is excess
    		this.opts = { x: 3, y: 6 };
    	}
    }

    In approximate order of severity based on "hits":

    • Initialization of a string-literal-typed field using a string always fails
    • Initialization of a function-typed field with a function expression doesn't un-implicit-any the parameters (and produces an any parameter)
    • Initialization of an array-typed field with an empty array triggers an implicit any (and produces an any field)
    • Initialization of an object-typed field with an object literal causes unspecified optional properties to disappear from the class property type

    Contextual Typing

    We very clearly need to be contextually typing initializer expressions with the type implied by the extends or implements clauses.
    This is the only plausible way we can fix parameters of function expressions.

    Computing Inferred Property Types

    What should the type of an initialized property with a matching property in the base type be (when there is no type annotation in the derived class)?
    Three appealing options:

    1. The type of the expression
    2. The type of the expression as contextually typed by the base type property
    3. The type of the base type property

    Option 1 is Terrible

    1. The type of the expression

    Option 1 is what we do today. See above list

    Option 2 is Terrible

    1. The type of the expression as contextually typed by the base type property

    We have a long-standing tenet that the type of a contextually-typed expression should not be observable. This currently shows up in assignment expressions:

    type Obj = { [n: string]: number } 
    
    // OK
    var x: Obj = { baz: 100 };
    x['bleh'] = 10;
    
    var y = x = { baz: 100 };
    y['bleh'] = 10; // Error, but really shouldn't

    and

    var x: string[] = []; // x: string[]
    var y = x; // y: string[]
    var z = x = []; // z: implicit any[]

    Simply using the contextually-typed type of the initializing expression also gives what appears to be "wrong" behavior for union types:

    interface I {
      xOrY: 'x' | 'y'
    }
    class C implements I {
      // Literals contextually typed by literal unions don't widen
      xOrY = 'x'; // xOrY: 'x'
      changeUp() {
      	this.xOrY = 'y'; // Error, cannot convert 'y' to 'x'
      }
    }

    Option 3 is a Breaking Change

    Breaks

    Currently, this code is OK:

    class Animal {
        parent = new Animal();
        move() { }
    }
    class Dog extends Animal {
        parent = new Dog();
        woof() {
            this.parent.woof();
        }
    }

    If we simply "copy down" the type of Animal.parent, then this.parent.woof() will be invalid.
    The user will have to write a type annotation instead:

        parent: Dog = new Dog();
    

    Of course, this code is already suspicious because it's unsound to writes through base class aliases:

    class Animal {
        parent = new Animal();
        move() { }
        setParent(c: Cat) {
            this.parent = c;
        }
    }

    but in general we are not strict about this kind of thing, and in practice this is a difficult "error" to make.

    Suspicious Changes?

    This code is the same as an earlier example, but the spelling has been changed:

    interface I {
        kind: 'widget' | 'gadget'
    }
    class C implements I {
        kind = 'widget';
    }

    It would appear that kind should be of type 'widget', not 'widget' | 'gadget'.
    Of course, today its type is string (and a compile error), but it's still perhaps unexpected.

  10. gcnew commented on Apr 25, 2017

    @gcnew
    Contributor

    I'd vote Option 2 for readonly properties and Option 3 for mutable ones.

  11. RyanCavanaugh commented on May 23, 2017

    @RyanCavanaugh
    MemberAuthor
  12. olegdunkan commented on Jun 26, 2017

    @olegdunkan

    Actually it is not an error

    y['bleh'] = 10; // Error, but really shouldn't

    A bracket notation property access of the form
    object [ index ]
    where object and index are expressions, is used to access the property with the name computed by the index expression on the given object. A bracket notation property access is processed as follows at compile-time:
    ...
    Otherwise, if index is of type Any, the String or Number primitive type, or an enum type, the property access is of type Any.
    spec
    I think you meant this:

    var foo = y['bleh']; //expect foo to be of number type not any;
  13. 1 remaining item

  14. kgtkr commented on Mar 1, 2018

    @kgtkr

    It is a similar issue

    function d(target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<(x: number) => void>) {
    }
    
    class A {
      @d
      f(x) { }//x is any
    }
  15. osdiab commented on Apr 11, 2018

    @osdiab

    This is another common case for me:

    import * as React from 'react';
    
    interface State {
      foo?: number
    }
    class A extends React.Component<{}, State> {
      state = { }
      render() {
        return <span>{this.state.foo}</span>; // ERROR, since property foo doesn't exist on this.state
      }
    }
    

    Instead I usually have to use the constructor to do this style of initialization, wish I wouldn't have to tell the engineers I'm working with to do that. This is a contrived example but you get the idea.

  16. schickling commented on Oct 18, 2018

    @schickling

    Is there any update on this issue? It makes the usage of this codegen implementation very cumbersome.

  17. Veetaha commented on Apr 27, 2019

    @Veetaha

    Is there any update on this issue? I am eager to see it in the closest releases.

    Regarding this feedback:

    Of course, there may be some breaking changes, but those will appear in bad code (with implicit any) and some of them can be fixed, like setting the type of the property by its initializer if it is assignable to the type from base/implemented interface. I think that this feature will bring more advantages (like less boilerplate and narrowing the sources of truth throughout the types of TypeScript apps).

  18. AnthonyLenglet commented on Oct 15, 2019

    @AnthonyLenglet

    currently we're fixing this by doing:

    interface IStore {
      id: string;
      storeCode: number;
    }
    
    export class Store implements IStore {
      public id: IStore['id'] = null;
      public storeCode: IStore['storeCode'] = null;
    
      constructor(item: IStore) {
        Object.assign(this, item, {});
      }
    }

    this works but a fix would definitely be really cool to get here !

  19. shicks commented on Nov 18, 2019

    @shicks
    Contributor

    It seems like #6118 had a mostly reasonable solution, except that it handled multiple inheritance via interfaces incorrectly. Under "The Bad" it listed an example like

    abstract class Base {
      abstract x: number|string;
    }
    interface Contract {
      x: number;
    }
    class Sub extends Base implements Contract {
      x = 42;
    }

    as failing because the type of x was taken from Base['x'] only, rather than (Base & Contract)['x'], which would have given the correct contextual type. This still does not address breakages when subtypes have a narrowed "kind":

    abstract class Base {
      abstract x: Animal;
    }
    class Sub extends Base {
      x = new Dog();
      method() { this.x.woof(); }
    }

    Potentially (as suggested earlier) the use of readonly (and/or as const?) could affect the contextualized type sufficiently to at least make fixes trivial? In the above example, if Base['x'] were readonly then presumably it's safe(r) to infer Sub['x'] as Dog? (Conversely, it's not actually an error but probably should be for only Sub['x'] to be readonly - the same thing might be fine there).

  20. jonlepage commented on Jan 28, 2021

    @jonlepage

    Same here by using ts with jsdoc in js for get ref, Intelisence is broke and lint type error !
    With the arrival of jsdoc and ts support, I hoped to be able to stay in js vanilla with full types support !
    image

    4yr old issue !, i suppose i will need search another architecture approach ! 😢
    pity, that restricts some ways to easily architect our projects ! hope a good ts guy can fix this someday !

  21. jonlepage commented on Jan 28, 2021

    @jonlepage

    ok actually this seem a good approach for me, but need using static for avoid import.
    image

    it's not too ugly and remains readable and accessible.
    But I'd rather want do without static and force types in the children class !

    const TypesEnum = {
    	abc: 'abc',
    	abcd: 'abcd',
    	abcde: 'abcde',
    };
    
    export class Base {
       static TypesEnum = TypesEnum
    	/**@type {keyof TypesEnum} - all work fine here, default null for the example purpose */
    	_test = null;
    	constructor(data) {}
    }
    
    export class A extends Base {
       /** @type {Base['_test']} */
    	_test = 'abc'; //lint a error and we lost intellisense in Vscode , need force @type!
    	constructor(data) {
    		super();
    	}
    }
  22. paddotk commented on Aug 17, 2022

    @paddotk
  23. shellscape commented on Jun 7, 2023

    @shellscape
  24. aikhan commented on Mar 5, 2024

    @aikhan
  25. jonlepage commented on Mar 5, 2024

    @jonlepage
  26. xorweak commented on Mar 3, 2025

    @xorweak
  27. jbjhjm commented on Jul 9, 2026

    @jbjhjm
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    In DiscussionNot yet reached consensusSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions