Issue
I have a union of tuples of varying lengths.
type Input =
| [a, b, c]
| [d, e]
| [f, g]
I want to "un-distribute" these into a single tuple. Each item in the tuple is an intersection of all of the items in all of the input tuples.
Transform<Input> // [a & d & f, b & e & g, c]
Some examples
// Merge objects
Transform<[ { foo: 1 } ] | [ { bar: 2 } ]> // [ { foo: 1, bar: 2 } ]
// Ignore labels
Transform<[x: { foo: 1 } ] | [ y: { bar: 2 } ]> // [ { foo: 1, bar: 2 } ]
// Items can be `never` when appropriate
Transform<[ number ] | [ string ]> // [ never ]
// Works with arrays
Transform<{ foo: 1 }[] | { bar: 2 }[]> // { foo: 1, bar: 2 }[]
// Works with spreads
Transform<[ { foo: 1 }, ...{ baz: 3 }[] ] | [ { bar: 2 }, ...{ boo: 4 }[] ]>
// [ { foo: 1, bar: 2 }, ...{ baz: 3, boo: 4 }[] ]
// Works with empty tuple
Transform<[]> // []
// Works with single tuple
Transform<[ 'a' ]> // [ 'a' ]
Solution
A caveat to start: this sort of recursive type juggling with conditional types and variadic tuple types tends to result in very bizarre edge case behavior. Anytime you've got a utility type like this you should be sure to test it thoroughly to make sure it behaves as expected with your use cases.
So, we want to write Transform<T>
where T
is expected to be a union of array/tuple types. It's going to be a tail-recursive conditional type that accumulates a partial result A
, so it will actually be Transform<T, A>
. The general approach is to split T
into a head element (similar to the union of the head elements of each member of T
), and a tail array (the union of the tail arrays of each member of T
). The head element needs to be intersected together, and we will recurse on the tail array.
There are three cases to detect:
- every member of
T
is the empty tuple[]
. That means we've run out of data to process; there is no head element or tail array. We can just return the accumulated resultA
- every member of
T
is a non-tuple array type. That means we've been asked to process a bunch of arrays without end and whose structure will not change further if we keep iterating. We can process the head element and then append an array of the resulting type as a rest element to the accumulated resultA
, and return it. - otherwise, at least one member of
T
is a tuple type with a head element and a tail array. This is the recursive step; we intersect all the head elements of the members that have them and append it toA
, and recurse on the tail array.
That looks like
type Transform<T extends any[], A extends any[] = []> =
Tail<T> extends never ? A :
NonRest<T> extends never ? [...A, ...IntersectHead<T>[]] :
Transform<Tail<T>, [...A, IntersectHead<T>]>;
Note that this is not a distributive conditional type. The check for those three cases needs to analyze the whole union input at once, not split it apart and analyze each piece separately.
That definition depends on Tail<T>
, which evaluates to the tail of the array:
type Tail<T extends any[]> =
T extends [any, ...infer R] ? R : T extends [] ? never : T;
So Tail<[H, ...R]>
should be R
, and Tail<X[]>
should be X[]
, but Tail<[]>
is never
. And since Tail
is a distributive conditional type, the only way never
comes out is for all the inputs to be empty.
The definition also depends on NonRest<T>
, which looks for even one tuple-like element, returning unknown
if it finds one and never
if it does not:
type NonRest<T extends any[]> =
T extends [any, ...any] ? unknown : T extends [] ? unknown : never;
Again, it's a conditional type. So if any element of T
is a tuple, the output will be unknown
. Only if NonRest<T> extends never
do we know that all elements are non-tuple array types.
And finally it depends on IntersectHead<T>
, which gives the intersection of the head elements of T
:
type IntersectHead<T extends any[]> = (
T extends [infer F, ...any] ? (x: F) => void : T extends [] ? never : (x: T[0]) => void
) extends (x: infer I) => void ? I : never
This is similar to UnionToIntersection
from Transform union type to intersection type, except that it first pulls the head element off the input instead of just intersecting the input directly. This allows things like Transform<[A | B] | [C | D]>
to evaluate to [(A | B) & (C | D)]
instead of [A & B & C & D]
.
Okay, let's test it out:
type Input = [A, B, C] | [D, E] | [F, G];
type Output = Transform<Input>; // type Output = [A & D & F, B & E & G, C]
type Z = Transform<[{ foo: 1 }] | [{ bar: 2 }]>
// type Z = [{ foo: 1; } & { bar: 2; }]
type Y = Transform<[x: { foo: 1 }] | [y: { bar: 2 }]>
// type Y = [{ foo: 1; } & { bar: 2; }]
type X = Transform<[number] | [string]>
// type X = [never]
type W = Transform<{ foo: 1 }[] | { bar: 2 }[]>
// type W = ({ foo: 1; } & { bar: 2; })[]
type V = Transform<[{ foo: 1 }, ...{ baz: 3 }[]] | [{ bar: 2 }, ...{ boo: 4 }[]]>
// type V = [{ foo: 1; } & { bar: 2; }, ...({ baz: 3;} & { boo: 4; })[]]
type U = Transform<[]>
// type U = []
type T = Transform<['a']>
// type T = ["a"]*/
Looks good, these are the types you wanted them to be.
Again, this needs to be tested thoroughly against a wide range of real-world use cases before one considers adopting it. Since none of the examples in the question involved optional elements in tuples then it might not do "the right thing" in such cases. I'd also be especially concerned about mixing and matching of different types of inputs. At this point any tweaking that needs to happen in response to such cases is out of scope for this question as asked, but it's still a good idea to do testing.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.