typescripttypescript-generics

Passing this as generic instance of abstract class method


typescript tells me this is assignable to type T, but T could be instantiated with different subtype of constraint MainModel

class HelperClass {
  public static someHelp<T extends MainModel<T>>(key: keyof T, instance: T): T[keyof T] {
    return instance[key];
  }
}

abstract class MainModel<T extends MainModel<T>> {
  someProperty: string = "";

  someMethod(key: keyof T) {

    // some code

    return HelperClass.someHelp(key, this); // <- error here
  }
}

class FirstModel extends MainModel<FirstModel> {}

const firstModel = new FirstModel();

firstModel.someMethod("someProperty");

I don't understand the error message, am I doing something wrong or my code is not complete?

What does it mean "could be instantiated with a different subtype" if my subtype is generic?


Solution

  • You are using a recursively bounded generic in class MainModel<T extends MainModel<T>>, presumably so that the generic type parameter T can be used as a synonym for the type of this inside the class. Unfortunately, it's not a synonym, it's a constraint. That is, T might not be identical to MainModel<T>; all you know is that T is some subtype of MainModel<T>. That leads to possibilities like

    class FirstModel extends MainModel<FirstModel> {
      x = 1;
    }
    
    class Oops extends MainModel<FirstModel> { }
    
    new Oops().someMethod("x").valueOf(); //allowed to compile, but runtime error 💥
    

    where FirstModel is actually more specific than MainModel<FirstModel> (as it has an extra x property), and thus you can write a class Oops that extends MainModel<FirstModel> without extending FirstModel. And so an Oops has a someMethod() that accepts "x" as an input, and the implementation of someMethod() will try to access the x property of this, and you get undefined, not T[keyof T]. Oops.

    This might not be likely to occur, but TypeScript doesn't realize that, so it complains. If you're confident that this won't be a problem, you can always assert that:

    someMethod(key: keyof T) {
      return HelperClass.someHelp(key, this as MainModel<T> as T); 
    }
    

    where the type assertion is how you acknowledge the possibility of the implementation being unsafe.


    In some other languages, notably Java and (at least historically) C++, recursively bounded generics (known as F-bounded polymorphism or the Curiously Recurring Template Patern) was the closest you could get to capturing the type of this.

    But in TypeScript, that use case is better served by using the polymorphic this type. The type named this is automatically the type of this, such that potential subclasses will have more specific this types. It's implicitly generic, and constrained to the current class, so it's very similar to class F<this extends F<this>> {} with a type parameter named this, but you have no opportunity outside the class to specify that with a weird type argument like FirstModel for Oops.

    If I switch to using polymorphic this, your problem goes away (and the code looks simpler since the generic is now implied and you don't need to carry it around everywhere):

    abstract class MainModel {
      someProperty: string = "";    
      someMethod(key: keyof this) {
        return HelperClass.someHelp(key, this); // okay
      }
    }
    class HelperClass {
      public static someHelp<T extends MainModel>(
        key: keyof T, instance: T): T[keyof T] {
        return instance[key];
      }
    }
    

    And if you try to reproduce the failure mode, you'll find it's not possible:

    class FirstModel extends MainModel {
      x = 1;
    }
        
    class Oops extends MainModel { }
    new Oops().someMethod("x").valueOf(); // <-- now this is a compiler error
    

    There's just no explicit type argument you need to "get right" anymore, it's automatically set to that of the current class.

    Playground link to code