typescriptclassgenericsextends

Why can't TypeScript override a method of the parent class with a derived type?


Here is a simple example.

type Callback<T> = (sender: T) => void;

class Garage<T> {

    private callbacks: Callback<T>[];

    public constructor(callbacks: Callback<T>[]) {
        this.callbacks = callbacks;
    }

    // Jobs in the garage are processed.
    public update(sender: T): void {
        for (const callback of this.callbacks) {
            callback(sender);
        }
    }
}

class Vehicle {
    public garage: Garage<Vehicle>; // You can also use Garage<this>, but this also generates errors.

    public constructor(...callback: Callback<Vehicle>[]) {
        this.garage = new Garage(callback);
    }

    public service(): void {
        this.garage.update(this);
    }

    public startEngine(): void {}
}


class Car extends Vehicle {
    public override garage: Garage<Car>; // You can also use Garage<this>, but this also generates errors.

    public constructor(...callback: Callback<Car>[]) {
        super(callback);
        this.garage = new Garage(callback);
    }

    public openTrunk(): void {};
}

new Vehicle((sender) => {
    sender.startEngine();
});

new Car((sender) => {
    sender.openTrunk();
});

Link to Playground

I don't understand where the mistake lies here. Car is derived from Vehicle.

Am I missing something or is it the generic classes and TypeScript can't check this properly?

What could an alternative solution look like, that you pass the appropriate class to the callback and TypeScript displays this correctly?


Solution

  • Functions are contravariant in their parameter types (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript), which means if T extends U, then Callback<U> extends Callback<T> and not vice versa. So conceptually a Car is not a Vehicle, since, as you've written it, a Vehicle has a Garage that accepts all Vehicles, but a Car has a Garage that only accepts Cars.

    Conceptually what you want to use is the polymorphic this type... in effect, this makes Vehicle implicitly generic, so that garage is of type Garage<this>, and where this will be (a generic subtype of) Car inside Car. The this type is an implicit sort of F-bounded generic, where it acts like class Vehicle<this extends Vehicle<this>> {}.

    Unfortunately you cannot write it this way because you can't use polymorphic this in the constructor. There's a longstanding open feature request for this at microsoft/TypeScript#5863, but for now it's not part of the language.

    What you could do instead (other than just using any and not worrying about it, or type assertions or the like) is to make Vehicle explicitly generic, in an F-bounded way:

    class Vehicle<T extends Vehicle<T>> {
        public garage: Garage<T>;
    
        public constructor(...callback: Callback<T>[]) {
            this.garage = new Garage(callback);
        }
    
        public service(this: T): void {
            this.garage.update(this);
        }
    
        public startEngine(): void { }
    }
    

    and then

    class Car extends Vehicle<Car> { ⋯ }
    

    The only added complexity is that TypeScript doesn't understand that the value named this is of type T (that's something you'd get for free with this types). So inside service(), you get an error on this.garage.update(this) by default. So you need to tell it that this is of type T, either by writing this.garage.update(this as T), or you need to tell it that service() is only callable on objects where this is of type T, by using a this parameter. That's the this: T inside public service(this: T): void. It's a virtual parameter, you don't actually pass it as an argument. It just tells TypeScript that foo.service() is only allowed to be call if foo is of type T, which we know extends Vehicle<T>.

    And now it should work as expected:

    const c = new Car((sender) => {
        sender.openTrunk();
    });
    c.garage.update(c); // okay, c is a Car
    

    Although you almost certainly never want to use new Vehicle by itself, since you'll get some crazy useless type out of it:

    const v = new Vehicle((sender) => {
        sender.startEngine();    
    });
    //const v: Vehicle<Vehicle<unknown>> // <-- wha?
    

    If you need something there, you can define a recursive BaseVehicle type and use it as a default:

    type BaseVehicle = Vehicle<BaseVehicle>;
    class Vehicle<T extends Vehicle<T> = BaseVehicle> {⋯}
    

    But I'd say it's better just to forget instantiating Vehicle, and only use concrete subclasses.

    Playground link to code