As of TypeScript 4.2, it's trivial to check if a tuple type begins or ends with a specific type:
type HeadIsString<T extends any[]> = T extends [string, ...any[]] ? true : false;
type TailIsString<T extends any[]> = T extends [...any[], string] ? true : false;
However, I'd like to check if a tuple includes a particular type at any position in the tuple. Something like this is not currently possible
type IncludesString<T extends any[]> = T extends [...any[], string, ...any[]] ? true : false;
because a tuple cannot have more than one rest element. The only solution I can think of is to turn the tuple into a union first.
type IncludesString<T extends any[]> = string extends T[number] ? true : false;
My specific use case is, given an array of functions, knowing if any of them return a Promise. For this specific use case, the above solution is sufficient. However, I could see other scenarios where this approach would not work. For example, where tuple elements could themselves be unions, but a type like string | null
would be undesirable. So in this example, [number, string | null]
would be transformed into number | string | null
and the type would evaluate to true
.
To clarify, I'm looking for a type that would, in our example, evaluate as follows:
IncludesString<[]> // false
IncludesString<[number]> // false
IncludesString<[[string]]> // false
IncludesString<[string, number]> // true
IncludesString<[number, string, number]> // true
The use of string
in this example is arbitrary -- I'm just looking for a generic approach that would avoid the aforementioned issue with using a union.
CodePudding user response:
Here is one possible approach:
type IncludesString<T extends any[]> =
{ [I in keyof T]: T[I] extends string ? unknown : never }[number] extends
never ? false : true;
The idea is that we want to map the tuple type T
to another tuple, where we turn anything extending string
into the unknown
"top" type, and anything not extending string
into the never
"bottom" type. So, for example, [string, number, string | number, "abc"]
would be transformed into [unknown, never, never, unknown]
, since both string
and "abc"
extend string
, while both number
and string | number
do not.
Once we have that mapped tuple, we index into it with number
to get the union of all the elements of the mapped tuple. In the previous example, that's [unknown, never, never, unknown][number]
, which becomes unknown | never | never | unknown
, which becomes unknown
. TypeScript immediately collapses any union of the form A | unknown
to unknown
, and any union of the form A | never
to A
. That means when you union together a bunch of types which are either unknown
or never
, the outcome will be unknown
if at least one of the input types is unknown
, and the outcome will be never
if none of the input types is unknown
.
And because we have transformed the tuple so that anything extending string
becomes unknown
and anything not extending string
becomes never
, the resulting union will be unknown
if and only if there's at least one member of the original tuple that extends string
.
So now all we have to do is map unknown
to true
and never
to false
. We do that with extends never ? false : true
(since unknown extends never
is false, and never extends never
is true).
Let's test it out:
type X0 = IncludesString<[]> // false
type X1 = IncludesString<[number]> // false
type X2 = IncludesString<[[string]]> // false
type X3 = IncludesString<[string, number]> // true
type X4 = IncludesString<[number, string, number]> // true
type X5 = IncludesString<[string | number]>; // false
type X6 = IncludesString<["abc"]> // true
type X7 = IncludesString<[string] | [number]> // true?
Most of those are exactly the output you wanted. The last one, though, IncludesString<[string] | [number]>
is true
, and it's not clear if that's what you want. Perhaps you want true | false
as the output. Or maybe false
. Who knows? There are always plenty of edge cases involved in custom type functions like this, and the only way to deal with that is to test thoroughly against your actual use cases and tweak the definition accordingly. But I consider that outside the scope of the question as asked.