I have an array of classes, in each class, there are different functions and variables. All the classes extend a single Abstraction:
export default abstract class Car {
public abstract name: string;
}
Classes like:
class Mercedes extends Car {
public name = 'Mercedes';
speed() {
return 70;
}
}
And:
class MazdaBusiness extends Car {
public name = 'Mazda';
height() {
return 12;
}
}
And:
class MazdaSport extends Car {
public name = 'Mazda2';
height() {
return 14;
}
}
I made a function that looks for a specific car by its name and returns it.
Class ParkingLot {
public cars: Car[];
constructor () {
this.cars = [new MazdaBusiness(), new MazdaSport(), new Mercedes()];
}
public getCar(name) {
const capitalizeFirstLetter = (string) =>
string.charAt(0).toUpperCase() string.slice(1);
const car = this.cars.find(
(car) => car.name === capitalizeFirstLetter(name)
);
return car;
}
}
I'm using TYPESCRIPT, how should I return the instance of the car with the correct Constructor type? For example, if I wrote 'Mercedes' it will return the Mercedes instance with the type Mercedes so I'll have an autocomplete and I'll be able to view all the variables and functions outside of the class.
getCar(“mazda2”) - will return MazdaSport instance with type of MazdaSport instead of Car type.
Thanks!!!
CodePudding user response:
You should just use a return type of Car.
public getCar(name): Car {
...
}
And then if you need to get a constructor you can access it through <your-instance>.constructor
just like in plain JS.
In theory you could have also used union type - however that would have been tedious and impractial since you would have had to include all subclasses of Car manually like Mercedes | Mazda
.
CodePudding user response:
First, we need some way for the compiler to match the actual literal type of a Car
's name
property to its class. When you write this:
class Mercedes extends Car {
public name = 'Mercedes';
// (property) Mercedes.name: string
}
the compiler infers that name
is of type string
, which is too wide for your purposes. For all the compiler knows or cares, someone could write let c = new Mercedes(); c.name = "Fiat";
, so it wouldn't have any reason to suspect that the string "Mercedes"
has anything to do with the type Mercedes
.
One way to address this is to mark name
as a readonly
property. Then the compiler will infer a narrower type:
class Mercedes extends Car {
public readonly name = 'Mercedes';
// ^^^^^^^^
// (property) Mercedes.name: "Mercedes"
}
Now the name
property of every Mercedes
is known by the compiler to be "Mercedes"
. So at the outset you should probably mark each class's name
field as readonly
.
Once you've done that, we need to give ParkingLot
enough type information for getCar()
to be implementable. In your code you declared the type of the cars
field to be Car[]
. Again, this is too wide for your purposes. It is important that the compiler know which subclasses of Car
are members of cars
.
The easiest way to do this is not to annotate the type of cars
at all, and just let the compiler infer its type from the value assigned in the constructor:
class ParkingLot {
public cars;
// (property) ParkingLot.cars: (Mercedes | MazdaBusiness | MazdaSport)[]
constructor() {
this.cars = [new MazdaBusiness(), new MazdaSport(), new Mercedes()];
}
}
You can see from above that the inferred type of cars
is (Mercedes | MazdaBusiness | MazdaSport)
, which is more specific than Car[]
. The compiler now knows that each element of cars
is in the union Mercedes | MazdaBusiness | MazdaSport
, which is necessary if there's any hope of getting the compiler to know what getCar()
will return.
Before we proceed I'm going to define a few helper types:
type SpecificCar = ParkingLot['cars'][number];
// type SpecificCar = Mercedes | MazdaBusiness | MazdaSport
The type SpecificCar
is the element type of the cars
array: the union of possible types that can come out of getCar()
. This is computed via two indexed access types: The type ParkingLot['cars']
is the type you get when you index into a ParkingLot
object with a key of type "cars"
; that is, it's the type of the cars
property of ParkingLot
, which is (Mercedes | MazdaBusiness | MazdaSport)[]
, an array type. And ParkingLot['cars'][number]
is the type you get when you index into that array type with a numeric index; that is, it's the type of the elements of the cars
array, which is Mercedes | MazdaBusiness | MazdaSport
.
type SpecificCarName = SpecificCar['name']
// type SpecificCarName = "Mercedes" | "Mazda" | "Mazda2"
The SpecificCarName
type is the union of name
properties corresponding to the subclasses of Car
in SpecificCar
(which is the type you get when indexing into a value of type SpecificCar
with a key of type "name"
). And finally:
type AcceptableCarName = SpecificCarName | Uncapitalize<SpecificCarName>;
// type AcceptableCarName = "Mercedes" | "Mazda" |
// "Mazda2" | "mercedes" | "mazda" | "mazda2"
These are the acceptable inputs to getCar()
; those that either exactly match the name
property of the elements of the cars
array, or are what you get when you lowercase the first character of that property value, using the Uncapitalize<T>
intrinsic string manipulation type.
Now we can start to write getCar()
. First let me describe the call signature:
public getCar<S extends AcceptableCarName>(
name: S
): Extract<SpecificCar, { name: Capitalize<S> }>;
This method is generic in S
, the type of the value you pass in for the name
argument. It is constrained to AcceptableCarName
, so it will only allow you to pass in "good" name
parameters.
The return type is Extract<SpecificCar, {name: Capitalize<S> }>
, using the Extract<T, U>
utility type. The idea is that we are filtering the SpecificCar
union to just the member (or members) assignable to {name: Capitalize<S> }>
, where the intrinsic string manipulation type Capitalize<S>
corresponds to the string S
where the first character has been converted to upper case.
Which means: if name
is of type S
, then the return type of getCar()
will be any elements of the SpecificCar
union whose name
property is S
with the first character uppercased... which is what the implementation of your function does.
That yields this behavior:
const lot = new ParkingLot();
const mazd = lot.getCar("mazda");
// const mazd: MazdaBusiness
const merc = lot.getCar("mercedes");
// const merc: Mercedes
const m2 = lot.getCar("mazda2");
// const m2: MazdaSport
const nothing = lot.getCar("yugo"); // error!
// ----------------------> ~~~~~~
// error! Argument of type '"yugo"' is not assignable to
// parameter of type AcceptableCarName
// const nothing: never
That's all great. When you pass good values into getCar()
, the compiler figures out which specific type of SpecificCar
comes out. lot.getCar("mazda")
is of type MazdaBusiness
, for example. And when you pass bad values in, there's a compiler error to warn you. (The return type in such cases is the impossible never
type, which is probably accurate, since the implementation is unlikely to be able to return anything at all in such situations.)
And now for the actual implementation:
public getCar<S extends AcceptableCarName>(
name: S
) {
const capitalizeFirstLetter = (string: string) =>
string.charAt(0).toUpperCase() string.slice(1);
const car = this.cars.find(
(car): car is Extract<
SpecificCar, { name: Capitalize<S> }
> => car.name === capitalizeFirstLetter(name)
);
if (!car) throw new Error("SOMEONE STOLE MY CAR");
return car;
}
This turns out to have the same call signature. I've annotated the callback to the find()
array method so that the compiler recognizes it as a user-defined type guard function. By returning a value of type car is Extract<SpecificCar, {name: Capitalize<S> }>
, I'm saying that if body car.name === capitalizeFirstLetter(name)
evaluates to true
, then car
is known to be of the specific car type we're trying to return.
After the call to this.cars.find()
, the type of car
is Extract<SpecificCar, {name: Capitalize<S> }> | undefined
because, as far as the compiler knows, the find()
method might not find anything. I deal with that with the added if (!car) throw ...
line before return car
. That convinces the compiler that car
will not be undefined
if the method returns, and so the return type is the desired type without undefined
.
So there you go. As a recap, we had to
- strongly type the
name
property ofCar
subclasses as string literal types, - strongly type the
cars
property ofParkingLot
, - give
getCar()
a suitable generic call signature, and then - implement the function to match that signature