How to define Map with correlation between a key type and a value type, while they both are unions

The intersection Map<CatId, Cat> & Map<DogId, Dog> should conceptually be enough to give you the behavior you want, but in practice this does not work. Intersections of function types produce overloads, and overloaded call signatures in TypeScript are resolved separately. They are not combined to allow a single call to invoke both call signatures (see microsoft/TypeScript#14107). So with just Map<CatId, Cat> & Map<DogId, Dog>, you cannot call animals.get(1 as AnimalId); an AnimalId is neither a CatId nor a DogId, as required by each of the two separate call signatures.


In order to address this, you have apparently added in the “missing” Map<AnimalId, Animal>. Unfortunately this goes too far. You not only get the desirable get() behavior, you get the undesirable set() behavior. Since cat.id is an AnimalId, and dog is an Animal, a Map<AnimalId, Animal> would certainly allow you to call animals.set(cat.id, dog). I won’t go into the pedantic details of covariance and contravariance, but generally speaking, if reading accepts unions-of-things, then writing should accept intersections-of-things, not unions. So the only methods of Map<AnimalId, Animal> you’d like to support are ones involving reading the contents.

Fortunately for us, there is a ReadonlyMap interface defined in the TypeScript standard library which serves just this purpose. So I’d be inclined to annotate animals like this:

const animals: Map<CatId, Cat> & Map<DogId, Dog>
  & ReadonlyMap<AnimalId, Animal> = new Map();

Once you do that, you get the behavior you’re looking for, at least for your example code:

animals.set(cat.id, cat) // okay
animals.set(cat.id, dog) // error, no overload matches this call
const test1: Cat | undefined = animals.get(cat.id) // okay
const test2: Dog | undefined = animals.get(dog.id) // okay
const test4: Animal | undefined = animals.get(1 as AnimalId) // okay
const test3 = animals.get(3) // error, number is not AnimalId

There may, of course, be other use cases that this definition does not support. Overloads do have some weird quirks. If you really truly care, you might need to handwrite your own interface that behaves exactly how you expect a multi-type Map to act. This is an interesting exercise I’ve done before for a different case (see this question) and honestly it’s not that terrible. But I won’t go into that here, especially if the above simpler intersection works for your use cases.

Playground link to code

Leave a Comment