Reputation: 3855
I have an abstract Vehicle
class that has abstract Wheel
s. I know that all subclasses of Vehicle
will compute their _wheels
in the same way, so to avoid duplication, I write the implementation in the base class:
abstract class Vehicle {
abstract _wheels: Wheel[];
wheels() {
return this._wheels; // assume it's more complex than this
}
}
abstract class Wheel {}
Then I create a specific subclass:
class Car extends Vehicle {
_wheels: CarWheel[];
constructor() {
super();
this._wheels = [];
}
}
class CarWheel extends Wheel {
hubcap = new CarWheelHubcap()
}
class CarWheelHubcap {}
It doesn't have Wheel
s but CarWheel
s with a CarWheelHubcap
. When it's time to enumerate them however, I run into a problem:
const car = new Car();
car.wheels().forEach(wheel => wheel.hubcap);
~~~~~~~~~~~~
Error: Property 'hubcap' does not exist on type 'Wheel'.
I would expect this not to happen since we've already re-declared _wheels
as CarWheel[]
. My guess is it goes up the prototype chain, finds Vehicle.prototype.wheels
and concludes its return type is Wheel
(?)
Is it possible to fix this without re-implementing wheels()
method in Car
class? Once again, the subclasses will all share Vehicle.prototype.wheels
so it'd be wasteful to re-write
wheels() {
return super.wheels() as CarWheel[];
}
in each subclass.
Upvotes: 1
Views: 208
Reputation: 31863
Given your sample code,
class CarWheelHubcap { }
class CarWheel extends Wheel {
hubcap = new CarWheelHubcap();
}
and continuing on to where you encounter the error,
const car = new Car();
car.wheels().forEach(wheel => wheel.hubcap);
Error: Property 'hubcap' does not exist on type 'Wheel'.
As the error says, Wheel
does not define a member named hubcap
.
Your approach of trying to redeclare, that is to say shadow, the _wheels
property in Car
with a more specific type has numerous problems. For one, it is not type safe because arrays are mutable. For another, the actual error stems from the return type of the wheels()
method being inferred from the type of the property _wheels
in the Vehicle
class, leaving you in a pickle of writing extra, pointless code or writing complex computed type annotations that aren't necessary for this relatively simple situation.
Instead we will leverage Parametric Polymorphism. This concept is exposed in TypeScript as Generics and you can read about them in the TypeScript: Handbook - Generics section.
It seems that you want to express that Car
in inheriting from Vehicle.prototype
, indeed has wheels, but that they are not merely Wheel
s but CarWheel
s. Inheritance isn't sufficient to express such a biaxial relationship, we need to use generics to express and enforce that relationship.
Let's take an initial stab:
class Vehicle<TWheel extends Wheel> {
abstract _wheels: TWheel[];
wheels() {
return this._wheels; // assume it's more complex than this
}
}
What we've done is parameterize Vehicle
with a type parameter, TWheel
that specifies what wheels it will have. Furthermore, we have stipulated that any type argument provided for the type parameter TWheel
must extend Wheel
.
Now in defining Car
, we must pass a type argument for TWheel
class Car extends Vehicle<CarWheel> {
_wheels: CarWheel[] = [];
}
Note: I removed the redundant empty constructor, replacing it with a field initializer. This is equivalent to constructor() {super(); this._wheels = [];}
while being easier to read and easier to maintain.
However, since the type of the elements of the _wheels
array is passed to Vehicle<TWheel>
when we extend it, we can improve the code further by writing removing the abstract
modifier from _wheels
in vehicle, implementing the array there and removing any need to define it in subclasses.
class Vehicle<TWheel extends Wheel> {
_wheels: TWheel[] = [];
wheels() {
return this._wheels; // assume it's more complex than this
}
}
And this simplifies Car
to
class Car extends Vehicle<CarWheel>{ }
With Car
parameterizing Vehicle
with CarWheel
, your original code now works.
car.wheels().forEach(wheel => wheel.hubcap);
The above now works because a Car
's wheels are known to be CarWheel
Note: To maintain compatibility with existing code, and to allow vehicles that don't have specialized wheels to extend vehicle simply and concisely, we can specify a default for our type parameter. Thus, if say another class, say representing armored vehicles, doesn't use specialized wheels, we can just write class Armor extends Vehicle { }
.
class Vehicle<TWheel extends Wheel = Wheel> {
_wheels: TWheel[] = [];
wheels() {
return this._wheels;
}
}
Finally, let's touch up the Vehicle
to make it more idiomatic. wheels
doesn't need to be a method, we can use a get
property accessor.
class Vehicle<TWheel extends Wheel = Wheel> {
get wheels() { return this._wheels; }
}
This let's consumers write car.wheels.forEach(...)
without thinking about the encapsulation technique ("oh this is a wrapper method") something they needn't worry their heads about.
Putting it together:
class Wheel {
diameter = 30;
}
class Vehicle<TWheel extends Wheel = Wheel> {
_wheels: TWheel[] = [];
get wheels() {
return this._wheels; // assume it's more complex than this
}
}
class CarWheelHubcap {
size = 15;
}
class CarWheel extends Wheel {
hubcap = new CarWheelHubcap();
}
class Car extends Vehicle<CarWheel> {
drivetrain: 'AWD' | 'FWD' | 'RWD' = 'AWD';
}
const car = new Car();
car.wheels
.map(wheel => wheel.hubcap)
.forEach(console.log);
Note that I've added a member to each class because empty classes are a poor practice that can lead to bugs and cause confusion and mayhem.
Also, if we need a constructor that takes an array of wheels, parametric polymorphism solution continues to work well for use. We can simply add a constructor to Vehicle
that takes the wheels, and all subclasses will expose an appropriately typed constructor requiring an array of the specified TWheel
. Playground Link
Upvotes: 1