Typescript: Inherited typing for class property initializers

Created on 27 Aug 2016  路  19Comments  路  Source: microsoft/TypeScript

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 @DanielRosenwasser @sandersn

In Discussion Suggestion

Most helpful comment

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

All 19 comments

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

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

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

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?

Nope, all properties

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

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

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.

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

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.

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

Actually it is not an error
ts 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](https://github.com/Microsoft/TypeScript/blob/master/doc/spec.md#413-property-access) I think you meant this: ts
var foo = y['bleh']; //expect foo to be of number type not any;
````

Any update on where this is at? It's causing some very ugly casts just to get things working.

It is a similar issue

function d(target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<(x: number) => void>) {
}

class A {
  @d
  f(x) { }//x is any
}

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.

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

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

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 !

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

Was this page helpful?
0 / 5 - 0 ratings

Related issues

siddjain picture siddjain  路  3Comments

blendsdk picture blendsdk  路  3Comments

CyrusNajmabadi picture CyrusNajmabadi  路  3Comments

zhuravlikjb picture zhuravlikjb  路  3Comments

weswigham picture weswigham  路  3Comments