The first my thought was to create a union type, like:
type ExactlyOne =
{ cat?: AllPropsOfSameType } |
{ dog?: AllPropsOfSameType } |
{ cow?: AllPropsOfSameType };
It's possible using distributive conditional types:
type ExactlyOne<T, TKey = keyof T> = TKey extends keyof T ? { [key in TKey]: T[TKey] } : never;
type ShouldBeExactlyOneOfPossibleProperties = ExactlyOne<PossibleProperties>;
Playground
But it still allows to assign an object with multiple properties:
// this assignment gives no errors
const animal: ShouldBeExactlyOneOfPossibleProperties = {
cat: 'a big cat',
dog: 'a small dog'
};
It's because union types in TypeScript are inclusive and you cannot create an exclusive union type at the moment. See this answer.
So we need to forbid additional properties somehow. An option might be to use never
type, but unfortunately it's impossible to create an optional property of type never
because never | undefined
gives undefined
. If it's ok to have undefined
additional properties you can use the following monstrous type:
type ExactlyOne<T, TKey = keyof T> = TKey extends keyof T
? { [key in Exclude<keyof T, TKey>]?: never } & { [key in TKey]: T[key] }
: never;
And the resulted type looks like:
({
dog?: undefined;
cow?: undefined;
} & {
cat: string | undefined;
}) | ({
cat?: undefined;
cow?: undefined;
} & {
dog: string | undefined;
}) | ({
cat?: undefined;
dog?: undefined;
} & {
cow: string | undefined;
})
It's horrible... but It's close to what is expected.
Playground
A disadvantage of this approach is an undescriptive error message if you try to assign an object with multiple properties, for example this assignment:
const animal: ShouldBeExactlyOneOfPossibleProperties = {
cat: 'a big cat',
dog: 'a small dog'
};
gives the following error:
Type '{ cat: string; dog: string; }' is not assignable to type '({ dog?: undefined; cow?: undefined; } & { cat: string | undefined; }) | ({ cat?: undefined; cow?: undefined; } & { dog: string | undefined; }) | ({ cat?: undefined; dog?: undefined; } & { cow: string | undefined; })'.
Type '{ cat: string; dog: string; }' is not assignable to type '{ cat?: undefined; dog?: undefined; } & { cow: string | undefined; }'.
Type '{ cat: string; dog: string; }' is not assignable to type '{ cat?: undefined; dog?: undefined; }'.
Types of property 'cat' are incompatible.
Type 'string' is not assignable to type 'undefined'.(2322)
Another approach: you can emulate an exclusive union type like suggested in this answer. But in this case an extra property is added to the object.
type ExactlyOne<T, TKey = keyof T> = TKey extends keyof T
? { [key in TKey]: T[TKey] } & { prop: TKey }
: never;
const animal: ExactlyOne<PossibleProperties> = {
prop: 'cat',
cat: 'a big cat'
};
Playground