Home > Back-end >  Is it possible to create a conditional type based on a literal array type?
Is it possible to create a conditional type based on a literal array type?

Time:03-08

I'm wondering if it's possible to create a conditional type based on if a value is in an array of literal values:

export type UseTablePlugins = "search" | "pagination";

function useTable<Plugins extends UseTablePlugins[]>(plugins: Plugins) {
  ...
  
  // This is the part I'm stuck on
  return searchState: Plugins extend "search"[] ? [string, Dispatch] : never;
}

I know TypeScript goes away on runtime and therefore is unable to find the values of variables, but I'm wondering if I do something like useTable(["search"]) if TypeScript will understand that search is in the type? I hope I'm making sense :).

EDIT: Here is a minimal example on TS playground.

CodePudding user response:

If you have an array type T extends UseTablePlugins[] and you want another type to evaluate to SearchState if "search" is present in the element type of T and null otherwise, then you might want to write the type like:

"search" extends T[number] ? SearchState : null;

The type T[number] is the element type of T, since it's the type you get when you index into an array of type T with an index of type number. Generally that will give you the union of the types of each known element type. So if T is the tuple type ["search", "pagination"], then T[number] is "search" | "pagination".

Note that when you use a (non-distributive) conditional type of the form AAA extends BBB ? true : false, you are going to get true if and only if you can assign a value of type AAA to a variable of type BBB. You can assign a value of type "search" to a variable of type "search" | "pagination" (it's safe to widen) but not vice versa (it's unsafe to narrow). So you need "search" extends T[number] and not T[number] extends "search" or T extends "search"[], which would be more like checking if only "search" appears in the array.


So then useTable() looks something like:

function useTable<T extends UseTablePlugins[]>(plugins: T) {
  const searchable = plugins.includes("search");
  const searchState = searchable ? useState("") : null;
  return searchState as "search" extends T[number] ? SearchState : null;
}

So let's see if it works how you want. This will depend pretty strongly on whether the compiler infers T narrowly enough to account for all the only the elements of the plugins parameter. Here are some happy cases:

const okay1 = useTable(["search"]); // SearchState
const okay2 = useTable(["search", "pagination"]); // SearchState
const null1 = useTable([]); // null
const null2 = useTable(["pagination"]); // null

In all of those cases, the compiler infers T as an unordered array type where the element type is no wider nor narrower than it should be. In the first case T is Array<"search">, and in the second it is UseTablePlugins[], both of which contain "search" in the element type. In the third case T is never[], and in the fourth it is Array<"pagination">, neither of which contain "search" in the element type.

Unfortunately there are still problematic edge cases:

const oops1 = useTable([Math.random() < 0.99 ? "pagination" : "search"]);
// SearchState, but 99% chance it should be null at runtime
const oops2 = useTable((["search", "pagination"] as const).slice(1))
// SearchState, but definitely null at runtime

In both of these cases, T is inferred as UseTablePlugins[]. The compiler knows that the elements of plugins are either "pagination" or "search" but it doesn't know that "search" will probably or definitely be absent. This is reasonable behavior for the compiler, but it results in the return type of useTable() being SearchState instead of null, which is not good. These edge cases occur because the implementation is too "optimistic" in some sense, and assumes that if "search" might be present, then it is present.

In general the compiler cannot know exactly which values will be in every array, so you can either err on the side of being too optimistic or too pessimistic. If you are too pessimistic the type will never be wrong, but it might be too wide to be useful. This would more or less be the situation where useTable() returns SearchState | null no matter what you pass in, or at least where you get SearchState | null unless the compiler is positive that "search" is definitely present or definitely absent. So you probably want the optimistic version, as long as you're reasonably careful to avoid the bad edge cases.

Playground link to code

  • Related