Home > OS >  Narrow down type of field, based on another field
Narrow down type of field, based on another field

Time:08-20

I have this interface:

enum CatEngine {
  Electric = "ev",
  Petrol = "petrol",
}

interface ElectricEngineDetails {
  range: number
}


interface PetrolEngineDetails {
  mpg: number
}

interface ICar {
  engine: CarEngine;
  details: ElectricEngineDetails | PetrolEngineDetails;
}

Is there a way to write a type guard that accepts an engine and a details, and returns the details, narrowed down to the corresponding engine type? I know it can be done in-place but I wish to extract it to a separate function.

CodePudding user response:

You could use a type instead of an interface for ICar(or Car since it's not an interface anymore):

type Car = { engine: CarEngine.Electric; details: ElectricEngineDetails } | { engine: CarEngine.Petrol; details: PetrolEngineDetails };

or you could create interfaces for the types:

type Car = IElectricCar | IPetrolCar;

interface IElectricCar {
  engine: CarEngine.Electric;
  details: ElectricEngineDetails;
}

interface IPetrolCar {
  engine: CarEngine.Petrol;
  details: PetrolEngineDetails;
}

CodePudding user response:

You can declare a generic interface, something like this:

type ICar<T extends CarEngine> = {
  engine: T;
  details: T extends CarEngine.Electric ? ElectricEngineDetails : PetrolEngineDetails;
}

This will compute the type for details depending on the type of engine. You can use it like this:

const ev: ICar<CarEngine.Electric> = {
 engine: CarEngine.Electric,
 details: {
  range: 23,
 }
}

Trying to use mpg will result in an error, as TS knows that details are of type ElectricEngineDetails.

With this you can write up a type guard function that does the type narrowing:

const isElectric = (car: ICar<CarEngine.Electric> | ICar<CarEngine.Petrol>): car is ICar<CarEngine.Electric> => {
  if (car.engine === CarEngine.Electric) {
    return true;
  }

  return false;
}

Now you can use it like this:

function something (car: ICar<CarEngine.Petrol> | ICar<CarEngine.Electric>): void {
   if (isElectric(car)) {
     car.details.range;
   } else {
     car.details.mpg;
   }
}

Here's a full example on playground.

CodePudding user response:

What you are looking for is a discriminated union

Basically, you need to define an union that is composed of all the possibilities, but all your interfaces must have a common and discriminant property that will allow Typescript to know how to eliminate types conditionally

You can test it here

enum CarEngine {
  Electric = "ev",
  Petrol = "petrol",
}

interface ElectricEngineDetails {
  range: number
}


interface PetrolEngineDetails {
  mpg: number
}

// Discriminant union, engine will be the discriminant property
type Car = IElectricCar | IPetrolCar;

interface IElectricCar {
  // Electric car will have 'ev' as a type for engine
  engine: CarEngine.Electric;
  details: ElectricEngineDetails;
}

interface IPetrolCar {
  // Petrol car will have 'petrol' as a type for engine
  engine: CarEngine.Petrol;
  details: PetrolEngineDetails;
}

declare const car: Car

// car types are discriminated based on the `engine` property
if (car.engine === CarEngine.Electric) {
  car.details.range // ok
  car.details.mpg // Doesn't exist
} else {
  // Removed IElectricCar from the union because Typescript know car.engine can
  // not be CarEngine.Electric anymore, so only IPetrolCar is available in the union
  car.details.mpg // ok
  car.details.range // Doesn't exist
}
  • Related