Can TypeScript infer `this` value correctly in child static methods?

Viewed 77

I have a scenario where I need a base class with a static method to instantiate the class. This base class is expected to be inherited by some children to have their own set of extra members.

Below is a minimal working example in JavaScript:

class A {
  static create(value) {
    return new this(value);
  }
  constructor(value) {
    this.value = value;
  }
}

class B extends A {
  log() {
    console.log(this.value);
  }
}

const test1 = B.create('test1');
const test2 = B.create.call(B, 'test2');
const test3 = A.create.call(B, 'test3');

test1.log();
test2.log();
test3.log();

In the static create method, it instantiates the class using new this so that it will automatically references the immediate parent class.

If it were to be written without using this, then all child classes will not be able to instantiate with the static create method because now the logic is locked to A only:

class A {
  static create(value){
    return new A(value);
  }
  ...
}

So far, so good.

Now, if I were to do this in TypeScript, it failed:

class A {
  value: string;

  static create(value: string) {
    return new this(value);
  }

  constructor(value: string){
    this.value = value;
  }
}

class B extends A {
  log(){
    console.log(this.value);
  }
}

const test1 = B.create('test1');
const test2 = B.create.call(B, 'test2');
const test3 = A.create.call(B, 'test3');

test1.log();  // Error: Property 'log' does not exist on type 'A'.
test2.log();  // Error: Property 'log' does not exist on type 'A'.
test3.log();  // Error: Property 'log' does not exist on type 'A'.

Playground

It appears that the type of this value is always locked to the root class.

Is there a way I can make the child classes infer this correctly?

1 Answers

The reason the snippet in question does not work is because the this value within A.create is only derived at runtime. Directly inferring it as its parent class is wrong because A.create can always be called with a totally unrelated this value:

A.create.call(String, 'abc');     // String { 'abc' }
A.create.bind(Boolean)('abc');    // Boolean { true }
A.create.apply(Number, ['abc']);  // Number { NaN }

In other words, TS cannot know beforehand the type of this, hence inference of this value to the parent class is not possible.

This is why we will need:

  • Generics to limit this to a fixed type (T extends typeof A).
  • Type assertion to explicitly tell TS that the type is InstanceType<T>.
class A {
  value: string;

  static create<T extends typeof A>(this: T, value: string): InstanceType<T> {
    return new this(value) as InstanceType<T>;
  }

  constructor(value: string){
    this.value = value;
  }
}

class B extends A {
  log(){
    console.log(this.value);
  }
}

const test1 = B.create('test1');
const test2 = B.create.call(String, 'test2');     // Error: StringConstructor is not assignable to type A.
const test3 = B.create.bind(Boolean)('test3');    // Error: No overload matches this call.
const test4 = B.create.apply(Number, ['test4']);  // Error: NumberConstructor is not assignable to type A.

test1.log();

Playground

Related