Home > Enterprise >  Return class instance with constructor type in Typescript
Return class instance with constructor type in Typescript

Time:09-30

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 of Car subclasses as string literal types,
  • strongly type the cars property of ParkingLot,
  • give getCar() a suitable generic call signature, and then
  • implement the function to match that signature

Playground link to code

  • Related