Given the following JavaScript function:
function x({foo, fooId, bar, barId}) {}
I want to convert it to TypeScript so that the caller must pass in either
foo
or fooId
, but not both. Likewise for bar
and barId
.
For example, x({foo: "", bar: ""})
and x({fooId: "", bar: ""})
would be allowed function calls, but the compiler would prevent x({foo: "", fooId: "", bar: ""})
and x({bar: ""})
.
Is this possible with the TypeScript type system and how to do it?
CodePudding user response:
The type you're looking for is this:
type XArgs =
{ foo: any; fooId?: never; bar: any; barId?: never; } |
{ foo: any; fooId?: never; barId: any; bar?: never; } |
{ fooId: any; foo?: never; bar: any; barId?: never; } |
{ fooId: any; foo?: never; barId: any; bar?: never; };
function x({ foo, fooId, bar, barId }: XArgs) { }
x({ foo: "", bar: "" }); // okay
x({ fooId: "", bar: "" }); // okay
x({ foo: "", fooId: "", bar: "" }); // error
x({ bar: "" }); // error
So XArgs
is a union with four possibilities. Let's look at the first one:
{ foo: any; fooId?: never; bar: any; barId?: never }
So foo
and bar
are properties of type any
, so they must be present. But fooId
and barId
are optional properties (signified by ?
) of the impossible never
type. There are no values of the never
type, so you can't actually supply a defined fooId
or barId
property there... and since optional properties may be omitted, then an optional property of type never
is essentially prohibited. So that type means that foo
and bar
are required, and fooId
and barId
are prohibited.
The other three union members are similar, except different properties are accepted and prohibited. Together, the four union members of type XArgs
describe the full range of allowable arguments to x()
.
So that's the answer to the question as asked.
But it could be prohibitively tedious to write out the necessary union manually, especially if you have more than two members of your exclusive unions (where you want exactly one element to appear), or more than two sets of properties you care about.
If so, you can have the compiler compute XArgs
as follows:
type AllKeys<T> = T extends unknown ? keyof T : never
type ExclusiveUnion<T, K extends PropertyKey = AllKeys<T>> =
T extends unknown ? (T & { [P in Exclude<K, keyof T>]?: never }) : never;
The AllKeys<T>
type is a distributive conditional type that computes the union of the keys of each union member of T
, so AllKeys<{a: 0} | {b: 1}>
is "a" | "b"
.
Then ExclusiveUnion<T>
type is another distributive conditional type that takes a union like {a: string} | {b: number} | {c: boolean}
and produces an exclusive version where each member explicitly prohibits members that only appear in other members. (It uses AllKeys
to get the keys of the other members.) It would be the equivalent of {a: string, b?: never, c?: never} | {a?: never, b: number, c?: never} | {a?: never, b?: never, c: boolean}
.
Notice I said "equivalent"; you actually get unions of intersections, like {a: string} & {b?: never, c?: never}
, which can get unwieldy.
So I have an Expand<T>
recursive conditional type that collapses intersections while it expands any other aliased properties:
type Expand<T> = T extends object ? { [K in keyof T]: Expand<T[K]> } : T;
And then we define XArgs
as an intersection of ExclusiveUnion
s, and Expand
it so that it's pretty:
type XArgs = Expand<
ExclusiveUnion<{ foo: any } | { fooId: any }> &
ExclusiveUnion<{ bar: any } | { barId: any }>
>;
which is exactly
type XArgs =
{ foo: any; fooId?: never; bar: any; barId?: never; } |
{ foo: any; fooId?: never; barId: any; bar?: never; } |
{ fooId: any; foo?: never; bar: any; barId?: never; } |
{ fooId: any; foo?: never; barId: any; bar?: never; };
Let's try it on a type that's harder to write out by hand:
type YArgs = Expand<
ExclusiveUnion<{ a: 0 } | { b: 1 } | { c: 2 }> &
ExclusiveUnion<{ x: 9 } | { y: 8 } | { z: 7 }>
>
/* type YArgs =
{ a: 0, b?: never, c?: never, x: 9, y?: never, z?: never; } |
{ a: 0, b?: never, c?: never, y: 8, x?: never, z?: never; } |
{ a: 0, b?: never, c?: never, z: 7, x?: never, y?: never; } |
{ b: 1, a?: never, c?: never, x: 9, y?: never, z?: never; } |
{ b: 1, a?: never, c?: never, y: 8, x?: never, z?: never; } |
{ b: 1, a?: never, c?: never, z: 7, x?: never, y?: never; } |
{ c: 2, a?: never, b?: never, x: 9, y?: never, z?: never; } |
{ c: 2, a?: never, b?: never, y: 8, x?: never, z?: never; } |
{ c: 2, a?: never, b?: never, z: 7, x?: never, y?: never; } */
Looks good!