0

When working with union types, generally it's easiest to just make an ADT with a tagged union. However, sometimes this isn't possible.

A representative case is React Router's <Route/> component. Here, there are three optional props: component, render, and children, but there must be 1 and exactly 1 of these 3 passed.

For simplicity I've pared down the types here, but the currently published types look sort of like this:

interface Route {
  render?: string
  component?: string
  children?: string
  someOtherProps?: any
}

They should really be more like this:

interface BetterRouteRender extends Route {
  render: string
  component?: undefined
  children?: undefined
}

interface BetterRouteComponent extends Route {
  render?: undefined
  component: string
  chldren?: undefined
}

interface BetterRouteChildren extends Route {
  render?: undefined
  component?: undefined
  Children: string
}

type BetterRoute =
  | BetterRouteRender
  | BetterRouteComponent
  | BetterRouteChildren

const invalidRouteBecauseEmpty: BetterRoute = {}
const invalidRouteBecauseDupe: BetterRoute = { render: 'render', component: 'component' }
const validRoute: BetterRoute = { render: 'render' }

So, two questions:

  1. Is there a better way of handling the above?
  2. If not, I am looking for help trying to write a type like this:
type BetterOptionalTypeConstructor<T, KS extends Array<keyof T>> = unknown

...which would be used something like BetterOptionalTypeConstructor<Route, ['render', 'component', 'children']>, and would spit out the BetterRoute type shown above.

I haven't put much effort into this yet, but it seems like Typescript doesn't currently support "mapped union types", as far as I can tell. Any insight on this front would be appreciated!

  • "but there must be 1 and exactly 1 of these 3 passed" - this is precisely what a tagged union is. However, you say "generally it's easiest to just make an ADT with a tagged union. However, sometimes this isn't possible" - it seems your real problem is not how to emulate a tagged union, but how to solve the underlying issue which makes using a tagged union "not possible" – user2407038 Sep 26 '19 at 21:49
  • 1
    Possible duplicate of https://stackoverflow.com/questions/42123407/does-typescript-support-mutually-exclusive-types. Check out the solution using an XOR to type an object's properties. – Mickey Sep 26 '19 at 21:56
  • @user2407038 have you read my example above referencing the React Router API? The reason for making a tagged union "not possible" here is that it seems quite impossible to submit a PR for an incredibly breaking change for a library with tens of thousands of users just to make a certain static typing pattern possible. – Timothy Hwang Sep 27 '19 at 03:21
  • @Mickey thanks, this looks promising. I was playing with conditional types as well but was having trouble composing them to handle an arbitrary number of keys. – Timothy Hwang Sep 27 '19 at 03:22
  • @TimothyHwang Indeed I did. You can use whichever representation for data you like in your user code and when you pass your data to library code, you first convert to the representation used by the library. When getting data from the library, you convert in the opposite direction. The representations have a 1-1 mapping so this conversion is simple (might not be cheap - but this is the price you pay for 'nice' code). My comment was just saying: "it seems to be possible to me" - but you didn't explain why you think its impossible, so maybe you know something I don't! – user2407038 Sep 27 '19 at 16:22
  • @user2407038 I would prefer a solution that operates at the type level rather than adding runtime burden if it isn't necessary. Wrapping the third party code with a better typed interface rather than figuring out a type-level solution seems like the lazy approach to me, honestly. – Timothy Hwang Sep 27 '19 at 20:48

1 Answers1

0

After doing some research from the link @Mickey posted, I came up with the following solution. Specifically, this Github discussion was very enlightening.

/**
 * @typedef Without
 *
 * Takes two record types `T` and `U`, and outputs a new type where the keys
 * are `keyof T - keyof U` and the values are `undefined | never`.
 *
 * Meant to be used as one operand of a product type to produce an XOR type.
 */

type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
/**
 * @typedef XOR
 *
 * Takes two record types `T` and `U`, and produces a new type that allows only
 * the keys of T without U or the keys of U without T.
 */
type XOR<T, U> = (T | U) extends object
  ? (Without<T, U> & U) | (Without<U, T> & T)
  : T | U;

// XOR is associative so we can compose it
type BetterRouteProps = RoutePropsCommon &
  XOR<RoutePropsComponent, XOR<RoutePropsRender, RoutePropsChildren>>;

// TypeError: missing properties
const invalidRouteMissingProps: BetterRouteProps = {};

// TypeError: too many properties
const invalidRouteTooManyProps: BetterRouteProps = {
  render: () => <div>Hello</div>,
  component: () => <div>Hello</div>
};

// Valid!
const validRoute: BetterRouteProps = {
  render: () => <div>Hello</div>,
  component: undefined // this is allowed
};

I've written up a more complete analysis here if anyone is interested.