Home > Back-end >  Multiple mutually exclusive parameters in TypeScript
Multiple mutually exclusive parameters in TypeScript

Time:12-06

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 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

  • Related