Carl Wirkus
Carl Wirkus

Reputation: 77

Typescript - Type definition for an array of objects from union type

I want to create a type definition for an array of objects from a union or enum type. I want the type definition to fail if not all entries in the union exist in the array as a value of a key in the array of objects.

ts playground

export type SelectValue<T = any> = {
  value: T;
  label: string;
};

type options = "email" | "sms";

//ideally make this type check fail because it does not have all of the values of the options union
const values: SelectValue<options>[]  = [
    {
        value: "email",
        label: "Email",
    },
];

Upvotes: 2

Views: 776

Answers (1)

jcalz
jcalz

Reputation: 327774

TypeScript doesn't have a built-in type corresponding to an "exhaustive array" in which every member of some union type is guaranteed to exist somewhere in the array. Nor can you easily create your own specific type that works this way, at least not if the union has a lot of members in it or if you want to allow duplicates.

For a union with only a handful of members and if you want to prohibit duplicates, then you can could generate a union of all possible acceptable tuple types, like [SelectValue<"email">, SelectValue<"sms">] | [SelectValue<"sms">, SelectValue<"email">] for your example code (see here). But that scales very badly in the number 𝑛 of members; the union of possible tuples containing one element for each member will itself have 𝑛! members (that's 𝑛 factorial), which gets very big, very quickly. Unions in TypeScript can only hold about 100,000 elements at most, and the compiler slows down noticeably before that. This means if you have even eight elements in your union, you'll have a bad time.


Instead the closest you can get in TypeScript is to write a generic type which acts as a constraint on the array type. That is, there's no ExhaustiveArray<U> type for union U; instead there's a generic ExhaustiveArray<T, U> type where T extends ExhaustiveArray<T, U> if and only if T is an array that exhausts all the members of U. And you'd need a helper function to stop you from writing out T yourself. That is, instead of const arr: ExhaustiveArray<MyUnion> = [...], you'd write const arr = exhaustiveArrayForMyUnion(...).

So let's define this:

type ExhaustiveArray<T extends readonly any[], U> =
  [U] extends [T[number]] ? T : [...T, Exclude<U, T[number]>]

const exhaustiveArray = <U,>() => <T extends readonly U[]>(
    ...t: [...T extends ExhaustiveArray<T, U> ? T : ExhaustiveArray<T, U>]) => t as T;

Here the ExhaustiveArray<T, U> is a conditional type which checks to see if the union U is completely accounted for by the union of all the elements of the array T. If so, it evaluates to T (and since T extends T, this will be a success). If not, it evaluates to a tuple with one more element than T at the end, containing everything missing (using variadic tuple types to append an element, and the Exclude utility type to compute the missing elements).

And the exhaustiveArray value is a curried helper function which takes a union type U and produces a new function that infers T from the value passed in, and then does the check. It's written in a weird way; it would be great if we could write <T extends ExhaustiveArray<T, U>>(...t: [...T]) => t, but that's illegally circular. Or if <T extends readonly U[]>(...t: [...ExhaustiveArray<T, U>]) => t worked, that would be great, but the compiler will not be able to infer T from t that way. The version above causes the compiler to first infer T from the value of t, and then convert that into ExhaustiveArray<T, U>. The whole point of this weird approach is to get a "good" error message when you pass in non-exhaustive arrays. A "bad" error message would be if the compiler would just say "that value isn't assignable to never", which is annoying because it doesn't help the developer know how to fix it.

Okay, let's test. First let's get the exhaustiveSelectValueArray() function:

const exhaustiveSelectValueArray = exhaustiveArray<
  { [K in Options]: SelectValue<K> }[Options]>();

where the type argument is a distributive mapped type that converts the Options union O1 | O2 | O3 | ... | ON into SelectValue<O1> | SelectValue<O2> | SelectValue<O3> | ... | SelectValue<ON>.

And here goes:

const values = exhaustiveSelectValueArray(
    { value: "email", label: "Email" },
    { value: "sms", label: "SMS" }
); // okay

const err = exhaustiveSelectValueArray(
    { value: "email", label: "Email" }
); // error! Expected 2 arguments, but got 1.

const err2 = exhaustiveSelectValueArray(
    { value: "email", label: "Email" },
    { value: "email", label: "SMS" }
); // error! Expected 3 arguments, but got 2.

The first call succeeds because both elements are passed in, while the second two calls fail because we're missing an argument. If you use IntelliSense to examine the function calls to see what argument is missing, both show that there was an expected argument of type SelectValue<"sms"> that wasn't passed in at the end.

Playground link to code

Upvotes: 2

Related Questions