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? Any suggestions on a more reasonable approach are appreciated!
CodePudding user response:
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({ ... });