Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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?

like image 574
Markus Mauch Avatar asked Aug 06 '21 12:08

Markus Mauch


People also ask

Can a generic implement an interface?

Only generic classes can implement generic interfaces. Normal classes can't implement generic interfaces.

How can use generics with interface in C#?

You can declare variant generic interfaces by using the in and out keywords for generic type parameters. ref , in , and out parameters in C# cannot be variant. Value types also do not support variance. You can declare a generic type parameter covariant by using the out keyword.

How do I get a class instance of generic type T?

The short answer is, that there is no way to find out the runtime type of generic type parameters in Java. A solution to this is to pass the Class of the type parameter into the constructor of the generic type, e.g.

What is generic function in TypeScript?

Generics allow creating 'type variables' which can be used to create classes, functions & type aliases that don't need to explicitly define the types that they use. Generics makes it easier to write reusable code.


2 Answers

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

like image 69
jcalz Avatar answered Oct 28 '22 18:10

jcalz


Imagine such a situation: you have

const myDog: Dog
const someOtherDog: IDog

and such a function:

function seeIfLikes(dog: IDog, anotherDog: IDog) {
  return dog.likes(anotherDog)
}

This function seems OK, IDog.likes() wants something that extends IDog as the argument.

But when you call seeIfLikes(myDog, someOtherDog), something unexpected happens: myDog is casted to an IDog, so TypeScript will forget that its likes() method requires something that extends Dog, not IDog!

So this function call will pass the type checking even if someOtherDog does not actually extend Dog - and if your Dog.likes() contains some code specific to the Dog class, not to IDog, you get a runtime kaboom.

This is why we cannot add new generic parameter restriction in subtypes: they may be casted to their supertypes, and that restriction will be gone. Hope this is clear enough to understand.


Yes, that Cat example will suffer from exactly the same problem, but tsc let it pass for unknown reason. Maybe it is a limitation of the type system, or a bug that is better reported.

like image 22
daylily Avatar answered Oct 28 '22 19:10

daylily