typescript abstract class method with union arguments

Viewed 577

Class

enum VehicleType {
  car = 'CAR',
  ship = 'SHIP',
}

interface BaseMoveDto {
  type: VehicleType;
}

interface CarMoveDto extends BaseMoveDto {
  type: VehicleType.car;
}

interface ShipMoveDto extends BaseMoveDto {
  type: VehicleType.ship;
}

type VehicleMoveDto = CarMoveDto | ShipMoveDto;

abstract class Vehicle {
  abstract move(dto: VehicleMoveDto): VehicleMoveDto;
}

class Car extends Vehicle {
  move(dto: CarMoveDto) {
    return dto;
  }
}

class Ship extends Vehicle {
  move(dto: ShipMoveDto) {
    return dto;
  }
}

Usage

class VehicleService {
  getVehicle(type: VehicleType) {
    switch (type) {
      case VehicleType.car:
        return new Car();
      case VehicleType.ship:
        return new Ship();
      default:
        throw new Error('invalid vehicle type');
    }
  }

  calculateMovement(dto: VehicleMoveDto) {
    const vehicle = this.getVehicle(dto.type);
    return vehicle.move(dto);
  }
}

Problem

Argument of type 'VehicleMoveDto' is not assignable to parameter of type 'never'.
  The intersection 'ShipMoveDto & CarMoveDto' was reduced to 'never' because property 'type' has conflicting types in some constituents.
    Type 'CarMoveDto' is not assignable to type 'never'.

error

According to the error, Typescript intersect move() arguments instead of union, making the dto argument become type never and cause the error. Anything I misunderstood about typescript? Are there any solution to do what I want?

Not a solution

I know I can do this without any problem.

class VehicleService {
  calculateMovement(dto: VehicleMoveDto) {
    switch (dto.type) {
      case VehicleType.car:
        return new Car().move(dto);
      case VehicleType.ship:
        return new Ship().move(dto);
      default:
        throw new Error('invalid vehicle type');
    }
  }
  otherMethod(type: VehicleType) {
    // other method use switch case as well
  }
}

But with implementation as above, I can't reuse the switch case for other methods.

1 Answers

The compiler is not able to understand the correlation between the type of vehicle and the type of dto. It sees vehicle as Car | Ship and dto as CarMoveDto | ShipMoveDto. Those uncorrelated union types are not wrong, but they do not contain enough information for the compiler to verify that vehicle.move(dto) is safe. For all the compiler knows, vehicle is of type Car, while dto is of type ShipMoveDto. You understand that this is impossible, but the compiler doesn't. I have filed an issue, microsoft/TypeScript#30581, as a feature request to support correlated union types, but I don't really expect anything can easily be done about it: it's more of a place to point people when they run into this issue.

The workarounds for this are either to be type-safe but redundant, or to be convenient but unsafe.


The redundant version is basically the one you mentioned, where you walk the compiler through all the possibilities. Your getVehicle() doesn't have enough type information to be used, since it always returns Car | Ship. If we change it to something like:

getVehicle<T extends VehicleType>(type: T) {
  return {
    get [VehicleType.car]() {
      return new Car();
    },
    get [VehicleType.ship]() {
      return new Ship();
    }
  }[type]
}

Then the compiler will understand that this.getVehicle(Vehicle.car) returns a Car and not a Car | Ship (by refactoring into a generic method whose implementation is an object property lookup with getters). And then you could write this:

calculateMovementRedundant(dto: VehicleMoveDto) {
  return dto.type === VehicleType.car ?
    this.getVehicle(dto.type).move(dto) :
    this.getVehicle(dto.type).move(dto);
}

which is fragile and redundant. I wouldn't recommend this unless type safety is more important to you than simple and idiomatic JavaScript.


The convenient version is to keep the emitted JavaScript the same, but use type-unsafe techniques such as type assertions to quell the compiler's concerns about those impossible cross-correlated dto/vehicle types.

In your case, you've already got a technically unsafe typing for the abstract Vehicle class's getVehicle() method. The parameter type VehicleMoveDto is actually too wide, since any subclass of Vehicle cannot be plausibly expected to accept an arbitrary VehicleMoveDto. You're taking advantage of Typescript's parameter bivariance for methods (method parameters are checked bivariantly even if you turn on --strictFunctionTypes).

That means if you just annotate vehicle as a Vehicle instead of letting the compiler infer the type as Car | Ship, it will suddenly accept a parameter of type CarMoveDto | ShipMoveDto:

calculateMovement(dto: VehicleMoveDto) {
  const vehicle: Vehicle = this.getVehicle(dto.type); // unsafe "widening"
  return vehicle.move(dto); // no error
}

That's a pretty easy way of getting things to compile. Keep in mind though that you are now responsible for the type safety here, since the compiler can't be. If you do something weird like this:

calculateMovementOops(dto: VehicleMoveDto) {
  const vehicle: Vehicle = Math.random() < 0.5 ? new Car() : new Ship(); // oops
  return vehicle.move(dto); // still no error
}

the compiler does not complain, but obviously you could have weird problems at runtime and even runtime errors. So be careful.

Playground link to code

Related