3

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?

Esko Luontola
  • 73,184
  • 17
  • 117
  • 128
  • By "_mutually exclusive_", do you mean you require at least one of them to be present? – ghybs Dec 06 '22 at 01:22
  • Yes. So it'll prevent `x({foo: "", fooId: "", bar: ""})` and `x({bar: ""})` but allow `x({foo: "", bar: ""})` and `x({fooId: "", bar: ""})`. Likewise for bar and barId. – Esko Luontola Dec 06 '22 at 01:26
  • "Mutually exclusive" does not, by itself, imply that one must appear, so you should say something like "exactly one of the properties `foo` and `fooId` should appear" instead – jcalz Dec 06 '22 at 01:31
  • What types do you want for these properties? I guess `any`? – jcalz Dec 06 '22 at 01:34
  • The type of the properties doesn't matter that much. In the project where I have this use case, the ID variable is a string and it'll cause a React component to fetch the object over the network. The non-ID variable is the object itself and that's used in tests to pass in test data. – Esko Luontola Dec 06 '22 at 01:37
  • 1
    Does [this approach](https://tsplay.dev/WK9RDN) meet your needs? If so I could write up an answer explaining it (although it might be a duplicate). If not, what am I missing? (Please mention @jcalz in your reply to notify me) – jcalz Dec 06 '22 at 01:38
  • @jcalz Thanks, that seems to work. Please write it up as an answer and explain that how it works. – Esko Luontola Dec 06 '22 at 01:41
  • 1
    May be related: [https://stackoverflow.com/questions/42123407/does-typescript-support-mutually-exclusive-types](https://stackoverflow.com/questions/42123407/does-typescript-support-mutually-exclusive-types) – Ricky Mo Dec 06 '22 at 01:48

1 Answers1

2

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 ExclusiveUnions, 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!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thanks. That's a very thorough explanation. It's teaching me lots about TS. ChatGPT's answers can't even compare: https://twitter.com/EskoLuontola/status/1599937038299824128 – Esko Luontola Dec 06 '22 at 02:47