0

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?

sizzle beam
  • 555
  • 4
  • 10

2 Answers2

1

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

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • Thanks! Your solution works, but isn't quite what I had in mind — I still want to be able to create unhappy `Driver`s (those who don't drive their favorite type of car) as well. My bad for not being more explicit about the requirements, though. However, your solution was able to get me on the right track by using mapped types! Since the StackOverflow comment length is limited, I'll post it as a separate answer. – sizzle beam Feb 15 '22 at 08:54
1

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', /*  Bob doesn't drive his favorite type of car, so this line causes a compiler error, which is what we want to happen. */
    },
  },
};

Playground link.

I've marked Titian's answer as the correct one anyway since it helped me find the answer I was looking for.

sizzle beam
  • 555
  • 4
  • 10