'Type discrimination based on *any* element of an array (not *all* elements)

I would like to create a union type in TypeScript that can use an array as a discriminant, but have a specific type match if any element of the array meets some criteria, rather than all elements in the array.

For example, this should work because any household with a dog in it can specify breeds:

const homes: Household[] = [{
  humans: 3,
  pets: ['cat', 'dog'],
  dogBreeds: ['mutt'],
}];

But this shouldn't, because households without dogs should never specify dog breeds:

const homes: Household[] = [{
  humans: 3,
  pets: ['cat'],
  dogBreeds: ['mutt'],
}];

My thinking is that I should define my types something like this:

type PetType = 'dog' | 'cat' | 'hamster';

interface NoDogHousehold {
  humans: number;
  pets: Exclude<PetType, 'dog'>[];
  dogBreeds?: never;
}

interface DogHousehold {
  humans: number;
  pets: 'dog'[];
  dogBreeds: string[];
}

type Household = DogHousehold | NoDogHousehold;

The problem with this is that DogHousehold will now only apply if all elements in pets are 'dog'. So it would work for ['dog'] or ['dog', 'dog'], but not ['cat', 'dog']. Using a tuple type could work, but I don't see any way I can create such a type without a predictable number/order of elements. The closest tuple type I could think of was ['dog', PetType?, PetType?], but this is awkward and only works if dog comes first.

Is there some way I can make TypeScript enforce type correctness like this? Or is this kind of type discrimination not possible?



Solution 1:[1]

To do this, we need a way for dogBreeds to know what pets is. For this, we need a generic:

type Household<Pets extends ReadonlyArray<string>> = {

And of course pets is of type Pets:

  pets: Pets;
}

Then we say, if Pets includes "dog", add dogBreeds:

 & (Pets extends [] ? {} : "dog" extends Pets[number] ? { dogBreeds: string[]; } : {})

But first we check if Pets is empty. Otherwise if it is empty, dogBreeds would show up anyways.

We intersect the result of this check with the base of { pets: Pets; }.

Then we can do it with cats too:

  & (Pets extends [] ? {} : "cat" extends Pets[number] ? { catBreeds: string[]; } : {})

However, we can't just use this type like this:

const house: Household = { ... };

TypeScript requires us to use a generic here, but then we'd need to duplicate code, which is not ideal.

To solve this, we need a wrapper function to do the inferring for us:

function household<Pets extends ReadonlyArray<string>>(household: Household<Pets>): Household<Pets> {
  return household;
}

And now we can use it:

const house = household({ ... });

Playground

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 hittingonme