XOR type
Generally, when dealing with a record value that may have one of several sets of keys, a discriminated union (also known as a tagged union or an algebraic data type) is the correct tool for the job. In Typescript, we can add a tag: a shared key with a unique value to each interface. Using this tag, we can then narrow the range of possible interfaces down to a known one.
Sometimes, however, we're not in control of the interface in question, and cannot add this tag. Other times, it is simply awkward to do so. One common case where this occurs is in React components that provide flexible APIs, where several different props are provided to solve a particular use case, and where these props are mutually exclusive.
As a simple example, imagine a <Button/>
component that has the following props: { size?: 'sm' | 'md' | 'lg' }
, and the following usage: <Button size="sm"/>
. We get the bright idea to encode these props as booleans, such that our props are now { sm?: boolean; md?: boolean; lg?: boolean }
, which lets us do <Button sm/>
, <Button md/>
, and <Button lg/>
. This is kind of neat, but what happens when someone does <Button sm md/>
? This is an invalid combination of props.
While I generally recommend avoiding this pattern and using ADTs to achieve a similar effect, sometimes dealing with tagless unions is unavoidable.
Real-world example: React Router
One frequently encountered case where this design pattern can be seen is in React Router's <Route/>
component. Three props are provided to handle rendering: component
, render
, and children
, and only one of the three should be provided.
The following usage note can be found in the documentation:
You should use only one of these props on a given
<Route>
. See their explanations below to understand the differences between them.
We can see what the interface looks like from the unofficial DefinitelyTyped declarations (with irrelevant types excluded):
There's nothing stopping us at the type level from passing all three props, or passing none of the props, both of which are invalid. So, how can we improve our types to catch these cases?
At least one, but not multiple
Our problem statement is that we want to be able to require at least one of several possible values, but not more than one. This interface can be modeled as such:
We want our output type to ensure that at least one of component
, render
and children
is provided, but not multiple. So how do we get our BetterRouteProps
?
Exclusive OR
For now, let's simplify our problem by pretending children
doesn't exist, so we only have to deal with component
and render
. Now, we need some sort of binary type combinator that requires exactly one of the two arguments. This is just the exclusive OR (XOR).
Thus, we need to create a generic type XOR<T, U>
that returns a type that enforces that either T
or U
is implemented, but not neither, and not both.
In attempting to solve this problem, I stumbled across a very interesting discussion on the Typescript Github repo, with several implementations of XOR
. I've adapted the one that I thought was clearest to the example below:
Using this type constructor, we can achieve our goal:
Going beyond binary
We're mostly there, but remember that we have three props we want to make exclusive, and our XOR
is a binary type constructor. However, recall that XOR
is both commutative and associative. Thus, we can compose our XOR
s to allow for any number of interfaces to verify.
Unfortunately, Typescript does not provide syntax for the composition of types, so we have to do this manually:
We can extend this by chaining as many XOR
s as we need.
This declaration is quite verbose. If anyone can give advice on writing a type type XOR<T, K extends keyof T>
that can be used like type EvenBetterRouteProps = XOR<RouteProps, 'render' | 'component' | 'children'>
, I'd love to see it! Currently it doesn't seem possible to create a non-record mapped type, but I've been surprised before.
Reddit user /u/TwiNighty suggested an alternative, much simpler approach for this use case that uses the same principles (though doesn't use XOR
). Thanks!
Downsides
One major downside of this approach comes from the way Typescript "unrolls" this type. For example, here's a sample type error returned by Typescript:
Pretty opaque if you're not already familiar with the internal workings of this type.
Even so, this is a nice trick to be aware of, when using an ADT is not an option.
Resources
Last updated