🤸🏻 记一次类型体操与其实战应用

本文是一篇引导探索式的学习笔记，希望你可以耐心、细心地跟着文章内容的节奏一点点理解，相信你会有一些收获！

刚开始只是想学习一下 TS 的一道常见题：Union to Tuple，把 "a" | "b" 这样的联合类型（Union type）转换为相应的 ["a", "b"] 这样一个元组（Tuple），这个题其实综合性还蛮强的，我们来看看怎么做。

熟悉使用 infer 作为工具来做操的同学可能第一时间会很自然地想到如下这种方式做一个条件类型推断（conditional type infer）：

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

结果并非如我们所想，这个 IncorrectTuple 的结果是 ["a", "a"] | ["b" | "b"]，这是因为在 TS 中 “对裸类型参数做条件类型推算” 被设计为 “有分配性”（distributive）的。即如果 extends 左边是联合类型，那么 TS 会把 extends 操作符左边的联合类型拆开做判断，所以实际上是在做：'a' extends ... 和 b extends ...，因此看来这个思路是行不通的。

想解这个题还得用到一些别的 “技巧性变换”、或者你也可以说是利用某些 TS 的特性。这些技巧属于 “知识性内容”，不是那种用你已经学过的知识推导出来的东西，所以想要增长自己的 “体操能力”，只能靠阅览和吸收过大量的例子，用足够的经验沉淀出来。

细细想一下我们要做到的事就不难发现，既然是要用 Union 类型中的每一个子项构造元组，第一步就是要能把上面例子里的 "a" 和 "b" 取出来。

先来看下面这个例 1：

Copy
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)

我们惊喜地发现，在对一个联合类型作条件类型推算后竟然可以得到一个交叉类型！这个计算是什么意思呢？我们来解读一下：

在 extends 左边的这个联合类型意思是：这是一个函数、它参数也是一个函数、而且单参，这个参数 arg 可能为 number 、也可能是 boolean。

而根据 TypeScript 的函数参数类型具有「 逆变 」这个特质 我们可以得出：arg 必须得是 U1_1 和 U1_2 作交叉类型，即 U1_1 & U1_2。若你不清楚这个结论的缘由，请认真阅读完这个链接里的文章理清协变和逆变的概念。

我们可以编写一个帮手类型来把 "a" | "b" 构造成 ((arg: (a: "a") => void) => void) | ((arg: (a: "b") => void) => void) ，然后再将它转换为交叉类型：

Copy
type UnionToFnUnions = U extends any
// 因为几乎所有类型都 extends any，
// 所以下面这儿我们可以带着 U 进行任意构造
? (k: (x: U) => void) => void
: never

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

我们再来看例 2，了解一个 TS 的特性：

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

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

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

我们得到这样一个结论：在 TS 中，多个签名不同的函数类型作交叉类型，等价于有多个重载的函数接口类型。而在这种接口类型上可以作如下的 infer 推断：

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

终于看到眉目了！我们总算找到了一条崎岖蜿蜒的路拿到最终期望的结果。但是这似乎并不太完美，因为最终能得到的元组种包含多少个元素，完全是取决于我们写了几个 infer！

是否有一个办法能 “循环” 地读取每一种函数的重载呢？

我们来看下面这个例子：

Copy
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"

欸！奇了怪了怎么只拿到了 "c" ？"a" 和 "b" 去哪了？哈哈不要惊慌，这里是 TypeScript 的设计使然。现在你可以把这一点也当作是某个 TS 的 “特性”、作为体操中的工具来用就好啦！

我们得到的结论是：在 TS 中，对多种函数重载作条件判断 infer 推算时，实际上只会以最后一种重载形式为目标。

把上面这个例子中学到的特性融合进刚才我们推算出的函数交叉类型，编写一个帮手类型：

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

现在可以取到 Union 中的每一个单项了，接下来我们需要再写一个帮手类型帮我们把它们塞入最终的元组：

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

很好！已经万事俱备啦，现在我们来编写最终的类型计算式：

Copy
type UnionToTupleRecursively<Union, Result extends any[]> = {
1: Result;
0: UnionToTupleRecursively<
Exclude<Union, IntersectionPop<Union>>, // 递归移除已经 Pop 出去的项
Prepend<IntersectionPop<Union>, Result> // 把 Pop 出来的塞入 Result
>
}[[Union] extends [never] ? 1 : 0];

type UnionToTuple = UnionToTupleRecursively<U, []>;

其实这道题做到这里就算结束了，但如果你和我一样是个有点小强迫症的人，试一下这个 UnionToTuple 后你会发现一个瑕疵：

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

得到的这个 FINAL_1 是一个带 Label 的元组，虽然这并不影响其性质，它和 ["a", "b"] 使用上应该说没有任何不同，但总归看上去有点怪，为了移除这个 Label，可以考虑再套一层如下的转换：

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

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

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

Union to Tuple 的题做到这里就告一段落了，我们小结一下我们学到了的各种性质：

对裸类型参数做条件类型推算是有分配性的

一个联合类型可以通过先把其中每一个子项都作为参数转化为一个函数的联合、再用参数的逆变性质转换为一个函数的交叉

函数重载的交叉类型可以改写为等价的接口形式

对多种函数重载作条件判断推算时，只会取最后一种重载形式作为目标

而最近 Volar 团队在为 Vue 的 emits 编写 LSP 服务中的类型时遇到了一个难题，我把问题的大意总结如下：

Vue 的 defineEmits 需要用户给定一个类型参数、它会根据这个类型参数返回要给类似 (event: 'foo', arg: T1) => void & (event: 'bar', arg1: T2, arg2: T3) => void 这样的类型。

现在我们需要从这个返回的交叉类型里提取出所有 “第一个参数” 的类型、并进行联合。

我们很容易推想得到，最终的类型计算式大概也会是类似上面那样的一个递归，最终得到的是一个 Tuple，虽然 Union to Tuple 很难，但是 Tuple to Union 就很容易了，只需要 Tuple[number] 一下就好了：

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

然后我们分别去实现上面这些作中间计算的帮手类型：

Copy
// 利用了我们上面总结的结论 4
type PopIntersectionFuncs = I extends (...args: infer A) => infer R ? (...args: A) => R : never

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

NarrowIntersection 这个类型有一点小坑值得一提，本来一开始我写的是这样的：

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

但这样最终得到的 Tuple 中总是有一个多余的 never，我料想这肯定是这里 Narrow 的时候出了问题，于是我便手写了一下中间步骤：

Copy
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
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 ---> 会终止递归、不会再有下一轮
// Result = [never, "foo", "bar", "fee"]

所以归根到底是这个 unknown 惹了祸，我们从网上援引一些好用的工具类型 IsUnknown 解决当前这个问题：

Copy
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

很好，现在对 Volar 的那个问题来做个最终的解答：

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

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

这个改动的 Pull Request 详见 这里，代码已合入 Volar Vue v1.8.4

参考

TypeScript 奇技淫巧 union to tuple

从 A|B 得到 A&B