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();
});
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?
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 Vehicle
s, but a Car
has a Garage
that only accepts Car
s.
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.