typescriptgenericstypesindex-signature

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


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?


Solution

  • 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