Home > Net >  Constraining an index signature (using generics) to make sub-properties have matching types
Constraining an index signature (using generics) to make sub-properties have matching types

Time:02-15

In TypeScript (v4.5.4), I am trying to define an object type via an index signature. I want TypeScript to enforce certain sub-properties in the object to have matching types, but those types are allowed to vary between top-level properties.

In the below (non-working) example, I want all happy drivers to drive their favorite car type. A mismatch between a driver's favorite car type and the actual type of their car should cause a TypeScript compiler error.

type CarType = 'Minivan' | 'Sports car' | 'Sedan' ; // | 'Pickup truck' |, etc. Imagine this union type has many possible options, not just three.

type Car = {
  carType: CarType
  // A car probably has many additional properties, not just its type, but those are left out of this minimal example.
  // The solution should be resistant to adding additional properties on `Car` (and the `Driver` type below).
};

type Driver = {
  favoriteCarType: CarType
  car: Car
};

/**
 * Happy drivers drive their favorite car type.
 */
const happyDrivers: { [name: string]: Driver } = {
  alice: {
    favoriteCarType: 'Minivan',
    car: {
      carType: 'Minivan', // ✅ Alice drives her favorite type of car.
    },
  },
  bob: {
    favoriteCarType: 'Sports car',
    car: {
      carType: 'Sedan', /* ❌ Bob doesn't drive his favorite type of car!
        This currently does not throw a compiler error because my types are too permissive, but I want it to. */
    },
  },
};

I've tried applying generics to the index signature and/or the Car and/or Driver type in all the ways I could think of, but I could not get the compiler to enforce the constraint that a driver's favoriteCarType must exactly match their car's carType.

Can you help me out?

CodePudding user response:

What you are looking for is the following union:

type Driver = {
    favoriteCarType: "Minivan";
    car: {
        carType: "Minivan";
    };
} | {
    favoriteCarType: "Sports car";
    car: {
        carType: "Sports car";
    };
} | {
    favoriteCarType: "Sedan";
    car: {
        carType: "Sedan";
    };
}

You can generate this union if you make Car generic (with the type of car being the type parameter) and we use a custom mapped type to create each constituent of the union (and then get a union indexing back into the resulting mapped type):


type Car<T extends CarType = CarType> = { carType: T };


type Driver = {
  [P in CarType]: {
    favoriteCarType: P
    car: { carType: P }
  }
}[CarType];

Playground Link

CodePudding user response:

Titian's answer put me on the right track by using mapped types. See the HappyDriver type below.

type CarType = 'Minivan' | 'Sports car' | 'Sedan' ; // | 'Pickup truck' |, etc. Imagine this union type has many possible options, not just three.

type Car = {
  carType: CarType
  // A car probably has many additional properties, not just its type, but those are left out of this minimal example.
  // The solution should be resistant to adding additional properties on `Car` (and the `Driver` type below).
};

/**
 * A driver may drive a car of any type, not just their favorite.
 */
type Driver = {
  favoriteCarType: CarType
  car: Car
};

/**
 * A happy driver drives their favorite type of car.
 */
type HappyDriver = {
  [C in CarType]: {
    [K in keyof Driver]: Driver[K] extends Car
      ? { [K2 in keyof Car]: Car[K2] extends CarType
        ? C
        : Car[K2]
      } : Driver[K] extends CarType
        ? C
        : Driver[K] 
  }
}[CarType]

const happyDrivers: { [name: string]: HappyDriver } = {
  alice: {
    favoriteCarType: 'Minivan',
    car: {
      carType: 'Minivan', // ✅ Alice drives her favorite type of car.
    },
  },
  bob: {
    favoriteCarType: 'Sports car',
    car: {
      carType: 'Sedan', /*            
  • Related