Search code examples
typescripttypescript-types

Why can't a generic member function in a class implementing an interface take an argument of the type of the class (instead of the interface)?


Considering an interface IDog with the method likes<T extends IDog>( other: T ). The method takes an argument whose type extends the interface. Why is not allowed to implement that method in a derived class Dog using the class as type of the argument instead of the interface?

interface IDog
{
    likes<T extends IDog>( other: T ): boolean;
}

class Dog implements IDog
{
    private name = "Buddy";
    public likes<T extends Dog>( other: T )
        // ^^^^^
        // error: Property 'likes' in type 'Dog' is not 
        // assignable to the same property in base type 'IDog' [...]
        // Property 'name' is missing in type 'IDog' but required in type 'Dog'
    {
        return true;
    }
}

Removing the private property name would make the error go away but is not a solution for my real world problem. The weird thing is though, that the same example without generics works just fine:

interface ICat
{
    likes( other: ICat ): boolean;
}

class Cat implements ICat
{
    private name = "Simba";
    public likes( other: Cat )  // no error using Cat here (instead of ICat)
    {
        return true;
    }
}

What am I missing here?


Solution

  • Let's imagine that the compiler had no problem with the way you are implementing IDog. Then the following would be fine:

    class Dog implements IDog {
      private name = "Buddy";
      public likes<T extends Dog>(other: T) {
        return other.name.toUpperCase() === "FRIEND";
      }
    }
    
    const myDog: IDog = new Dog(); // should be okay if Dog implements IDog
    

    But that can lead to runtime errors that would not be caught by the compiler:

    const eyeDog: IDog = {
      likes(other) {
        return true;
      }
    }
    console.log(myDog.likes(eyeDog)) // okay for the compiler, but RUNTIME ERROR
    

    So the compiler is right that Dog does not properly implement IDog. Allowing this would be "unsound". If you have a function type you want to extend (make more specific), you cannot make its parameters more specific and be sound; you need to make them more general. This means that function parameters should be checked contravariantly (that is, they vary the opposite way from the function type... they counter-vary... contravariant).


    Of course that leads to your question about Cat. Doesn't the exact same argument work there?

    class Cat implements ICat {
      private name = "Simba";
      public likes(other: Cat) { // no error
        return other.name.toUpperCase() === "FRIEND";
      }
    }
    const myCat: ICat = new Cat(); // no error
    
    const eyeCat: ICat = {
      likes(other) { return true; }
    }
    
    console.log(myCat.likes(eyeCat)) // no compiler error, but RUNTIME ERROR
    

    Indeed it does! The compiler is allowing the unsound extension of ICat with Cat. What gives?

    This is explicitly intentional behavior; method parameters are checked bivariantly, meaning the compiler will accept both wider parameter types (safe) and narrower parameter types (unsafe). This is apparently because, in practice, people rarely write the sort of unsafe code above with myCat (or myDog for that matter), and such unsafeness is what allows a lot of useful type hierarchies to exist (e.g., TypeScript allows Array<string> to be a subtype of Array<string | number>).


    So, wait, why does the compiler care about soundness with generic type parameters but not with method parameters? Good question; I don't know that there's any "official" answer to this (although I might have a look through GitHub issues to see if someone in the TS team has ever commented on that). In general, the soundness violations in TypeScript were considered carefully based on heuristics and real-world code.

    My guess is that people usually want type safety with their generics (as evidenced by microsoft/TypeScript#16368's implementation of stricter checks for them), and specifically adding extra code to allow method parameter bivariance would be more trouble than it's worth.

    You can disable the strictness check for generics by enabling the --noStrictGenericChecks compiler option, but I wouldn't recommend intentionally making the compiler less type safe, since it will affect much more than your Dog issue, and it's hard to find resources for help when you rely on unusual compiler flags.


    Note that you may be looking for the pattern where each subclass or implementing class can only likes() parameters of its own type and not every possible subtype. If so, then you might consider using the polymorphic this type instead. When you use this as a type, it's like a generic type that means "whatever type the subclass calling this method is". But it's specifically made to allow the kind of thing you seem to be doing:

    interface IGoldfish {
      likes(other: this): boolean;
    }
    
    class Goldfish implements IGoldfish {
      private name = "Bubbles";
      public likes(other: this) {
        return other.name.toUpperCase() === "FRIEND";
      }
    }
    const myFish: IGoldfish = new Goldfish();
    

    This, of course, has the same problem as the other two examples:

    const eyeFish: IGoldfish = { likes(other) { return true; } }
    console.log(myFish.likes(eyeFish)) // RUNTIME ERROR
    

    so it's not a panacea for unsoundness. But it is very similar to the generic version without the generic parameter warning.

    Playground link to code