banner
沈青川

旧巷馆子

愿我如长风,渡君行万里。
twitter
jike

🤸🏻 A Record of Gymnastics Types and Their Practical Applications

Avatar

This article is a guided exploratory learning note. I hope you can patiently and carefully follow the rhythm of the article's content to understand it bit by bit. I believe you will gain something!

At first, I just wanted to learn about a common TypeScript problem: Union to Tuple, which converts a union type like "a" | "b" into the corresponding tuple ["a", "b"]. This problem is quite comprehensive, so let's see how to tackle it.

Those familiar with using infer as a tool might naturally think of the following way to perform a conditional type inference:

type UnionToTuple<Union> = Union extends infer A | infer B ? [A, B] : never;
type IncorrectTuple = UnionToTuple<"a" | "b">;

However, the result is not what we expected. The result of IncorrectTuple is ["a", "a"] | ["b" | "b"], which is because in TypeScript, "performing conditional type inference on bare type parameters" is designed to be "distributive." That is, if the left side of extends is a union type, TypeScript will break down the union type on the left side of the extends operator for judgment. So, it is actually doing: 'a' extends ... and b extends ..., which shows that this approach does not work.

To solve this problem, we need to use some other "tricky transformations," or you could say we need to leverage certain features of TypeScript. These tricks belong to "knowledge content," not something derived from knowledge you have already learned. Therefore, to enhance your "gymnastics ability," you can only rely on reading and absorbing a large number of examples to accumulate enough experience.

Upon careful consideration of what we need to achieve, it is not difficult to realize that since we want to construct a tuple from each item in the Union type, the first step is to extract "a" and "b" from the example above.

Let's look at Example 1:

type U1_1 = (a: number) => void
type U1_2 = (a: boolean) => void

type U1 = ((arg: U1_1) => void) | ((arg: U1_2) => void)

type TEST = U1 extends (arg: infer I) => void ? I : never

// TEST: ((a: number) => void) & ((a: boolean) => void)

We are pleasantly surprised to find that performing conditional type inference on a union type yields an intersection type! What does this computation mean? Let's interpret it:

The union type on the left side of extends means: this is a function, its parameter is also a function, and it has a single parameter. This parameter arg can be either number or boolean.

According to TypeScript's function parameter types having the property of "contravariance", we can conclude that arg must be the intersection type of U1_1 and U1_2, i.e., U1_1 & U1_2. If you are unclear about the reason for this conclusion, please read the article in the link carefully to clarify the concepts of covariance and contravariance.

We can write a helper type to construct "a" | "b" into ((arg: (a: "a") => void) => void) | ((arg: (a: "b") => void) => void), and then convert it into an intersection type:

type UnionToFnUnions<U> = U extends any
	// Since almost all types extend any,
	// we can construct with U here
	? (k: (x: U) => void) => void
	: never

type UnionToFnIntersections<U> = UnionToFnUnions<U> extends (arg: infer I) => void ? I : never

Now let's look at Example 2 to understand a feature of TypeScript:

type T1 = (
  ((arg1: number) => void) 
  & ((arg1: boolean) => void)
)

interface T2 {
  (arg1: number): void
  (arg1: boolean): void
}

type T3 = T1 extends T2 ? true : false
//   ^? true

We reach the conclusion: In TypeScript, multiple function types with different signatures as an intersection type are equivalent to a function interface type with multiple overloads. On such an interface type, we can perform the following infer inference:

type FnIntersectionToTuple<T> = T extends {
    (x: infer A): void;
    (x: infer B): void;
} ? [A, B] : never;

Finally, we see a glimmer of hope! We have found a winding path to obtain the desired result. However, this seems imperfect because the number of elements in the final tuple depends entirely on how many infer we wrote!

Is there a way to "loop" through each function overload?

Let's look at the following example:

type F1 = (arg: "a") => void
type F2 = (arg: "b") => void
type F3 = (arg: "c") => void

type F_TEST1 = (F1 & F2 & F3) extends { (a: infer R): void; } ? R : never;
// ^? "c"

Hey! How come we only got "c"? Where did "a" and "b" go? Don't panic; this is due to TypeScript's design. You can treat this as a certain "feature" of TypeScript, to be used as a tool in gymnastics!

Our conclusion is: In TypeScript, when performing conditional inference on multiple function overloads, only the last overload form will be targeted.

We can integrate the feature learned from the above example into the function intersection type we inferred earlier and write a helper type:

type IntersectionPop<U> = UnionToFnIntersections<U> extends { (a: infer A): void; } ? A : never;

Now we can extract each item from the Union, and next we need to write another helper type to help us insert them into the final tuple:

type Prepend<U, T extends any[]> =
	((a: U, ...r: T) => void) extends (...r: infer R) => void ? R : never;

Great! Everything is ready. Now let's write the final type calculation:

type UnionToTupleRecursively<Union, Result extends any[]> = {
  1: Result;
  0: UnionToTupleRecursively<
	   Exclude<Union, IntersectionPop<Union>>, // Recursively remove the already popped items
       Prepend<IntersectionPop<Union>, Result> // Insert the popped items into Result
     >
}[[Union] extends [never] ? 1 : 0];

type UnionToTuple<U> = UnionToTupleRecursively<U, []>;

Actually, the problem ends here, but if you, like me, are a bit of a perfectionist, you will find a flaw after trying this UnionToTuple:

type FINAL_1 = UnionToTupleRecursively<"a" | "b", []>
//   ^? [a: "a", a: "b"]

The resulting FINAL_1 is a labeled tuple. Although this does not affect its nature, it should be said that there is no difference in usage from ["a", "b"], but it looks a bit strange. To remove this label, we can consider wrapping it in another transformation as follows:

type RemoveLabels<Tuple, Result extends any[]> = Tuple extends [infer E, ...infer Rest]
  ? RemoveLabels<Rest, [...Result, E]>
  : Result

type UnionToTuple<U> = RemoveLabels<UnionToTupleRecursively<U, []>, []>;

type FINAL_2 = UnionToTupleRecursively<"a" | "b", []>
//   ^? ["a", "b"]

The Union to Tuple problem concludes here. Let's summarize the various properties we have learned:

  1. Performing conditional type inference on bare type parameters is distributive.
  2. A union type can be transformed into a function union by treating each item as a parameter, and then converted into a function intersection using the property of parameter contravariance.
  3. The intersection type of function overloads can be rewritten as an equivalent interface form.
  4. When performing conditional inference on multiple function overloads, only the last overload form will be targeted.

Recently, the Volar team encountered a problem while writing LSP services for Vue's emits types. I summarized the essence of the problem as follows:

Vue's defineEmits requires users to provide a type parameter, and it will return a type similar to (event: 'foo', arg: T1) => void & (event: 'bar', arg1: T2, arg2: T3) => void.
Now we need to extract all "first parameter" types from this returned intersection type and union them.

We can easily speculate that the final type calculation will likely be a recursive one similar to the above, ultimately resulting in a Tuple. Although Union to Tuple is challenging, Tuple to Union is quite easy; we just need to do Tuple[number]:

type ExtractFirstArgRecursively<I, Result extends any[]> = {
  1: Result;
  0: ExtractFirstArgRecursively<
       NarrowIntersection<I, PopIntersectionFuncs<I>>,
       Prepend<GetIntersectionFuncsLastOneFirstArg<I>, Result>
     >
}[[I] extends [never] ? 1 : 0];

Then we implement the helper types for intermediate calculations:

// Utilizing the conclusion we summarized in 4
type PopIntersectionFuncs<I> = I extends (...args: infer A) => infer R ? (...args: A) => R : never

type GetIntersectionFuncsLastOneFirstArg<I> = I extends (firstArg: infer F, ...rest: infer P) => void ? F : never

The NarrowIntersection type has a small pitfall worth mentioning. Initially, I wrote it like this:

type NarrowIntersection<I, T> = I extends (T & infer R) ? R : never

However, this always resulted in an extra never in the final Tuple. I suspected that the narrowing here was problematic, so I manually wrote out the intermediate steps:

type E1 = ((event: 'foo', arg: number) => void)
type E2 = ((event: 'bar', arg1: string, arg2: number) => void)
type E3 = ((event: 'fee', arg: boolean) => void)

type TT = (E1 & E2 & E3)

type Last1 = PopIntersectionFuncs<TT>
type NTT1 = NarrowIntersection<TT, Last1>
//   ^? ((event: 'foo', arg: number) => void) & ((event: 'bar', arg1: string, arg2: number) => void)
//   Result = ["fee"]

type Last2 = PopIntersectionFuncs<NTT1>
type NTT2 = NarrowIntersection<NTT1, Last2>
//   ^? (event: 'foo', arg: number) => void
//   Result = ["bar", "fee"]

type Last3 = PopIntersectionFuncs<NTT2>
type NTT3 = NarrowIntersection<NTT2, Last3>
//   ^? unknown
//   Result = ["foo", "bar", "fee"]

type Last4 = PopIntersectionFuncs<NTT3>
type NTT5 = NarrowIntersection<NTT3, Last4>
//   ^? never ---> will terminate recursion, no further rounds
//   Result = [never, "foo", "bar", "fee"]

So ultimately, this unknown caused the issue. We can reference some useful utility types from the internet, IsUnknown, to solve this problem:

type IsAny<T> = 0 extends 1 & T ? true : false
type IsNever<T> = [T] extends [never] ? true : false
type IsUnknown<T> = IsAny<T> extends true
  ? false
  : unknown extends T
	? true
	: false

type NarrowIntersection<I, T> = I extends (T & infer R)
  ? IsUnknown<R> extends true
     ? never
     : R
  : never

Great! Now let's provide a final answer to Volar's problem:

type GetAllOverloadsFirstArg<I> = RemoveLabels<ExtractFirstArgRecursively<I, []>, []>

type FINAL = GetAllOverloadsFirstArg<TT>
// ["foo", "bar", "fee"]

This pull request with the changes can be found here, and the code has been merged into Volar Vue v1.8.4.

References#

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.