A generic TypeScript interface with method that returns exactly one of union type

Viewed 488

I have a generic React function component where T extends ReactText | boolean, and its props contain a method that returns T.

import React, { FC, ReactText } from 'react';

interface Thing {}

interface Props<T extends ReactText | boolean> {
  value: T | null;
  mapValues(thing: Thing): T;
}

interface MyComponent<T extends ReactText | boolean> extends FC<Props<T>> {}

function MyComponent<T extends ReactText | boolean>(
  { value, mapValues }: Props<T>
) {
  // do stuff
  return <div />;
}

However, I want to enforce that the method returns exactly one of ReactText and boolean; it cannot be a function that returns either.

import React, { FC, ReactText } from 'react';

interface Thing {
  someBool: boolean;
  someStr: string;
}

interface Props<T extends ReactText | boolean> {
  value: 
    boolean extends T ? boolean | null : 
    ReactText extends T ? ReactText | null : 
    never;

  mapValues(thing: Thing): 
    boolean extends T ? boolean : 
    ReactText extends T ? ReactText :
    never;

  things: Thing[];
}

interface MyComponent<T extends ReactText | boolean> extends FC<Props<T>> {}
function MyComponent<T extends ReactText | boolean>(
  { value, mapValues, things }: Props<T>
) {
  if (value != null) {
    return <div>{value}</div>;
  }

  // super simplified verison of what I'm actually doing
  const items = things
    .map(mapValues)
    .map(v => <li key={v.toString() /** assume v is unique */}>{v}</li>);

  return <ul>{items}</ul>;
}

While decidedly ugly, it does work (much to my surprise after writing this out in TS Playground).

const things: Thing[] = [
  { someStr: 'a', someBool: false },
  { someStr: 'b', someBool: true }
];

const value = null;
const mapValues = (thing: Thing) => 
  things.find(t => t.someStr === thing.someStr)?.someStr ?? 
  things.find(t => t.someBool === thing.someBool)?.someBool ??
  false;

// succeeds but is ugly and potentially fragile
// @ts-expect-error
<MyComponent things={things} value={value} mapValues={mapValues} />;

// this one works
// I don't know why `as ReactText` is needed when `ReactText` is `string | number`.
<MyComponent things={things} value={'abc' as ReactText} mapValues={() => 'abc' as ReactText} />;
Type '(thing: Thing) => string | boolean' is not assignable to type '(thing: Thing) => boolean'.  
  Type 'string | boolean' is not assignable to type 'boolean'.  
    Type 'string' is not assignable to type 'boolean'.

I can't imagine, though, that it is the best solution. It's verbose and feels fragile.

Is there an XOR type that isn't based on interface properties, as that doesn't work in this case? Perhaps using Extract<T, U>?

TS Playground

2 Answers

You are running into the situation that generic type parameters cannot easily be constrained to one of a set of types. See microsoft/TypeScript#27808 for a relevant feature request.

Say you have a type parameter T and three types A, B, and C, and you want to constrain T to one of those three types. The seemingly obvious approach T extends (A | B | C) is insufficient. It allows everything you want to allow, but it does not prohibit everything you want to prohibit. For example, it allows A | B because (A | B) extends (A | B | C), even though neither (A | B) extends A nor (A | B) extends B is true in general.

You really want a constraint like T extends_oneof {A, B, C} or possibly (T extends A) | (T extends B) | (T extends C), but that's not valid TypeScript and there's no direct alternative.

In your case you want T extends_oneof {ReactText, boolean}, but all you have is T extends ReactText | boolean, which allows things you don't want, like string | true.


So we have to work around it. We can build a type function called OneOf<T, C> that takes a type T and a tuple of constraints C, and return T if and only if it extends one of the members of C, and never otherwise. Here's one way to do it:

type OneOf<T extends C[number], C extends readonly any[]> =
  Extract<{ [I in keyof C]-?: [T] extends [C[I]] ? T : never }[number], T>;

type O = OneOf<"abc", [string, number, boolean]> // "abc"
type P = OneOf<123, [string, number, boolean]> // 123
type Q = OneOf<"abc" | 123, [string, number, boolean]> // never

If you don't care about having this work for an arbitrary length tuple of constraints, you can build the equivalent function for, say, a pair of constraints:

type OneOfTwo<T extends C1 | C2, C1, C2> =
  [T] extends [C1] ? T : [T] extends [C2] ? T : never;

which might be easier to understand. In both cases I'm using [T] extends [XXX] instead of T extends X to prevent the check from being distributive in unions which would defeat the purpose of the check.


Anyway, it would be amazing if you could write your type so that you constrain T to OneOf<T, C>:

interface IWish<T extends OneOf<T, [string, number, boolean]>> { // error!
  // -------------------> ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  // Type parameter 'T' has a circular constraint.(2313)
  a: T
}

but that's an illegal circular constraint. No such luck. So any use of OneOf<T, C> will need to be outside of the constraint; possibly used in place of T in other type locations. This is easy enough in practice, but it's still a limitation that makes this more of a workaround than a solution.

It would also be nice if, instead of never, an invalid T passed to OneOf<T, C> would produce an "invalid type" so that the compiler would issue an error right there. See microsoft/TypeScript#23689 for that relevant feature request. But again, no such luck. That means any use OneOf<T, C> will rely on never resulting in an error. This is easy enough in practice, but again, workaround.


Okay, enough caveats. Let's see it in action. I'd recommend leaving your Props definition alone, and only worry about constraining T in your MyComponent function... like this:

function MyComponent<T extends ReactText | boolean>(
  props: Props<OneOf<T, [ReactText, boolean]>>
) {

  const { value, mapValues, things } = props as Props<T>;

  // ...    
}

Note that inside the function I assert props to Props<T> from Props<OneOf<T, [ReactText, boolean]>>, since the former is easier to manipulate inside the function; conditional types depending on unresolved generic type parameters are hard to handle.

Let's test out the calling side (I'm using regular call style instead of JSX style so we can manually specify and/or inspect the type parameter):

MyComponent({ things, value: "abc", mapValues: () => "def" }); // okay
MyComponent({ things, value: true, mapValues: () => false }); // okay
MyComponent({ things, value: true, mapValues: () => "def" }); // error
// Type '"def"' is not assignable to type 'true' -> ~~~~~
MyComponent<boolean | ReactText>(
  { things, value: null, mapValues: () => 123 }); // error
// -------------------------------------> ~~~
// Type 'number' is not assignable to type 'never' 

Looks good! The allowed calls work as desired. The disallowed calls are disallowed as desired also. In the last call, you can see that we manually specify boolean | ReactText for T, which results in the component needing to be of type Props<never> which doesn't work because mapValues() is not returning never.


Playground link to code

(edit: i found out that both boolean and ReactText are both union types themselves, which makes this solution problematic for your use case)

This is a type that will evaluate to never for union types

type NoUnionTypes<T> = [T] extends infer TTuple // keeps T "together"
  // distributive conditional, blows T apart if it's a union
  ? T extends infer U  
    ? TTuple extends [U] ? T : never 
    : never 
  : never;

(there is probably a better name for it)

Using this type, we can write your Props interface like so:

interface Props<T extends ReactText | boolean> {
  value: NoUnionTypes<T> | null;
  mapValues(thing: Thing): NoUnionTypes<T>;
  things: Thing[];
}

Link to TS Playground

Related