TypeScript 如何分配联合类型
摘要
一篇深度文章,解释 TypeScript 如何在重载函数、方法接收者和条件类型中分配联合类型,并包含示例和解决方法。
<p><a href="https://lobste.rs/s/g5mmir/how_typescript_distributes_unions">评论</a></p>
查看缓存全文
缓存时间: 2026/06/03 09:45
# norswap · TypeScript 如何分发联合类型
来源:https://norswap.com/typescript-distribute/
2026年5月31日
在过去几周里,我用 TypeScript 写了一个小库,过程中对类型系统学到了很多。这是第一篇文章,重点关注 TypeScript 如何处理联合类型。
这段代码能通过类型检查吗?如果通过,`x` 的类型是什么?
```typescript
function foo(it: string): string
function foo(it: number): number
function foo(it: number | string): number | string { return it }
declare const it: number | string
const x = foo(it)
```
那这个呢?
```typescript
class Foo {
foo(): string { return "1" }
}
class Bar {
foo(): number { return 1 }
}
declare const r: Foo | Bar
const x = r.foo()
```
最后一个问题,在下面的代码片段中,`x` 的类型是 `Foo<A | B>` 还是 `Foo<A> | Foo<B>`?
```typescript
class Foo<T> {
constructor (readonly value: T) {}
copy(): Foo<T> { return new Foo(this.value) }
}
declare const r: Foo<A> | Foo<B>
const x = r.copy()
```
第一个例子无法编译,第二个可以。本文将解释原因,并展示如何创建与第一个例子功能等价的函数签名。第三个例子中 `x` 的类型是 `Foo<A> | Foo<B>`(可赋值给 `Foo<A | B>`)。这正是我们需要的!不过,我们也会探讨如何强制函数输出 `Foo<A | B>`,因为有时这么做会很方便。
本文主要面向对语言细节感兴趣的读者。如果你也有好奇心,那就一起深入吧。
- [重载分发](#overload-distributivity)
- [接收者分发](#receiver-distributivity)
- [条件类型分发](#conditional-distributivity)
- [通过泛型签名实现“重载”](#overloads-via-generic-signatures)
- [使用提取类型压缩同构联合](#squashing-homogeneous-unions-with-extractor-types)
- [总结](#summary)
## 重载分发
让我们来看开头的例子:
```typescript
function foo(it: string): string
function foo(it: number): number
function foo(it: number | string): number | string { return it }
declare const it: number | string
const x = foo(it) // TS2769: 没有与此调用匹配的重载。
```
`foo` 是一个重载函数。在 TypeScript 中,重载的工作方式与 Java 等传统静态类型语言不同。在 Java 中,每个重载都是独立的函数,有自己的实现,只是碰巧名字相同。而在 TypeScript 中,是一个实现对应多个签名。
TypeScript 本可以像 Java 那样做:将每个重载编译成不同名字的 JavaScript 函数,并根据类型信息静态地将每个调用点分配到合适的重载。之所以不这么做,可能有两个原因:首先,这个系统允许为单个已有的 JavaScript 函数编写多个类型签名。其次,TypeScript 通常避免复杂的代码生成:TypeScript 编译器主要只检查类型,然后将其剥离。(也有例外,比如 TypeScript 的 `enum` 确实会生成代码,不过生成是局部的,不会影响调用点。)
无论如何,我们的参数类型是 `number | string`,允许调用并将 `x` 的类型定为 `number | string` 显然是正确的。
**在匹配函数参数时,TypeScript 不会将签名分发到联合类型上。**
然而,对于方法接收者(`x.foo()` 中的 `x`)则不同。下一节将讨论这一点,再下一节将介绍一些必要的背景知识,然后我们将展示如何真正实现这种重载签名!
## 接收者分发
对于接收者,逻辑更复杂。**基本思路是 TypeScript 尝试在接收者之间寻找匹配的签名,匹配过程不包括返回类型,因此分发是可能的!** 因此,我们的例子可以工作,并将 `x` 的类型定为 `string | number`:
```typescript
class Foo {
foo(): string { return "1" }
}
class Bar {
foo(): number { return 1 }
}
declare const r: Foo | Bar
const x = r.foo() // x: string | number
```
现在我们来详细拆解这个过程。当在联合类型接收者上调用方法时,TypeScript 会收集所有分支上的方法定义。它会用具体的类型实参实例化类类型参数(如果有的话)。然后尝试构建一组统一签名,基本做法是将每个分支上的每个签名与其他所有分支上的另一个签名进行匹配。如果无法在所有联合分支上找到匹配,该签名就会被丢弃。签名的匹配条件如下:
- 它们的函数类型参数形状相同(数量、约束和默认值)
- 它们具有相同数量的参数,且参数类型完全相同(不仅仅是兼容)
- 不过允许可选参数
- 如果存在类型参数,返回类型必须完全匹配,即使不包含类型参数也是如此
- 这是为了防止返回类型包含类型参数时出现问题,语言作者显然不关心无问题的情况
每个匹配集生成一个统一签名,其返回类型是该集合中所有签名返回类型的联合。得到的统一签名集随后与参数进行匹配。如果没有任何匹配,就会得到“TS2769: 没有与此调用匹配的重载。”如果没有生成任何统一签名,TypeScript 会回退到次要算法,但仅当最多只有一个分支有重载时才适用。在这种情况下,它会取每一个重载(如果没有任何分支有重载,则仅取单个签名),并生成与另一个分支上(单个)签名统一的签名。这些统一签名与初始方法中的不同:参数类型变成了跨分支的交叉类型。返回类型和之前一样是联合类型。更精确地说:协变位置中的类型取并集,逆变位置中的类型取交集(如果你不懂这意味着什么,不必太担心)。如果存在显式的 `this` 参数,它会像其他参数一样被处理。
在次要算法中,被统一的签名可以有不同数量的参数。较短的参数列表不会为它们没有的参数提供类型信息。类型参数的处理也更宽松:允许一侧有参数列表而另一侧没有。否则签名必须兼容,但默认值可以不同。总是应用联合其中一个分支的默认值(或默认值的缺失!)。如果其他分支有默认值,那些默认值总是被丢弃。选择哪个分支是非确定性的!(它依赖于不保证的加载逻辑。)
如果次要算法产生的签名集也为空,你可能会得到以下错误:
- “TS2339: 属性 '...' 在类型 '...' 上不存在。”——当某个联合分支完全缺少某个签名时
- “TS2349: 此表达式不可调用。联合类型的每个成员都有签名,但这些签名彼此都不兼容。”——在其他所有情况下
- 如果签名集非空但无法将参数匹配到任何签名,你会得到:
- “TS2769: 没有与此调用匹配的重载。”——如果最终集中有多个签名
- “TS2345: 类型 '...' 的参数不能赋值给类型 '...' 的参数。”——如果最终集中只有一个签名
**这并不总是安全的。** 如果有多个重载匹配参数,通常按出现顺序优先级排序。但签名匹配过程可能导致某些签名消失。考虑以下例子,假设 `Foo.foo` 是一个处理参数的函数,字符串会自动解析为数字:
```typescript
class Foo<A> {
foo(x: string): number
foo(it: A): A
foo(it: A | string): A | number {
return typeof it === "string" ? Number.parseInt(it) : it
}
}
class Bar<A> {
foo(it: A): A { return it }
}
declare const r: Foo<number> | Bar<number>
const x = r.foo("42") // x: "42"
```
这里 `x` 的类型会被定为 `"42"`,因为第一个 `foo` 签名在匹配过程中消失了。然而实现保持不变。这意味着如果 `r` 被赋值为 `Foo` 的实例,那么 `x` 将包含 `42`,但类型为 `"42"`,这是一个类型健全性问题。
## 条件类型分发
在我们讨论如何实现几乎任意“重载”签名的方法之前,需要先了解条件类型(即类型级别表达式 `A extends B ? C : D`)。
如果条件左侧是裸露的,条件类型会在联合类型上分发;如果被包裹起来,则不会分发。防止分发的常见方法是将其包裹在元组中:
```typescript
type Foo<T> = T extends "a" ? "A" : T extends "b" ? "B" : "C"
type Test = Foo<"a" | "b" | "c"> // "A" | "B" | "C"
type Foo2<T> = [T] extends ["a"] ? "A" : [T] extends ["b"] ? "B" : "C"
type Test2 = Foo2<"a" | "b" | "c"> // "C"
```
这个例子应该一目了然:在 `Foo1` 中,联合类型被分发,每个小写字符串类型都映射到对应的大写字符串类型。在 `Foo2` 中,`T` 是整个联合类型,最终落到了默认情况(`"C"`)上。
**条件类型中的 `never`**
`never` 在条件类型中的行为就像一个空联合。对条件类型分发 `never` 会得到 `never`。
```typescript
type TestNever = Foo<never> // never
type TestNever2 = Foo2<never> // "A"
```
**额外说明:条件类型中的 `any`**
虽然与当前话题无关,但值得注意的是:尽管 `any` 既是顶层类型也是底层类型(意味着它同时是其他所有类型的超类型和子类型),但 `any extends X` 对除 `any` 和 `unknown` 之外的任何 `X` 都求值为 `false`。这是一个可以理解的选择,因为在所有情况下都没有好的解决方案。
## 通过泛型签名实现“重载”
重载提供的核心能力是:为单个函数定义多个参数列表,并将每个参数列表映射到一个返回类型。通过一些技巧,我们可以实现其中的大部分功能,**并且**允许联合类型上的分发。
我将介绍的方法非常适合参数列表相对同质的情况:每个参数有一个或多个可能的类型,并且所有组合都是有效的。对于非常异质的参数列表,仍应使用重载(而且联合分发可能根本不适用)。
要使用泛型模拟重载(以单参数函数为例),我们需要:
- 可能的参数类型的联合。
- 一个受该联合约束的类型变量,用于捕获参数的具体类型。
- 一个嵌套的条件类型,将具体参数类型映射到具体返回类型,用作返回类型。
以标题中的例子为例:
```typescript
function foo(it: string): string
function foo(it: number): number
function foo(it: number | string): number | string { return it }
declare const it: number | string
const x = foo(it) // TS2769: 没有与此调用匹配的重载。
```
解决方案如下:
```typescript
type FooReturn<T> = T extends number ? number : string
function foo<T extends number | string>(it: T): FooReturn<T> {
return it as FooReturn<T>
}
declare const it: number | string
const x = foo(it) // number | string
const y = foo(42) // number
```
你可能注意到这里过于复杂了,返回类型本来可以简单地设为 `T`。在这个例子中确实可以,但在参数类型和返回类型不同的所有情况下,你都需要条件类型。
请注意,遗憾的是 TypeScript 无法推断返回类型与约束匹配,因此需要进行类型断言。
**如何处理联合?** 很简单:记住条件类型会在联合上分发,所以 `FooReturn<T>` 会单独映射联合的每个分支。
一个特别有趣的联合类型是其中每个分支都是同一个类型的不同参数化版本。考虑引言中的例子:
```typescript
class Foo<T> {
constructor (readonly value: T) {}
copy(): Foo<T> { return new Foo(this.value) }
}
declare const r: Foo<A> | Foo<B>
const x = r.copy() // 类型为 `Foo<A> | Foo<B>`
const y: Foo<A | B> = r.copy()
```
到 `x` 的赋值为止,并没有什么特别新颖的地方:我们已经看到 TypeScript 可以在接收者上分发方法。`y` 的赋值很有趣:只要 `T` 是协变类型参数,`Foo<A> | Foo<B>` 总是可以赋值给 `Foo<A | B>`。
“`Foo<T>` 在 `T` 上协变”意味着如果 `B` 可赋值给 `A`,那么 `Foo<B>` 可赋值给 `Foo<A>`。当 `T` 只出现在“输出”位置(返回类型中,而不是参数类型中)时就是这种情况。实际上,TypeScript 在这方面非常宽松,为了保证遗留兼容性,会将很多本不安全的情况也视为协变。关于 TypeScript 中的协变、逆变和不变性的完整讨论本身就值得一篇独立的文章——这里只需一个协变的快速定义就足够了。
注意,虽然 `Foo<A> | Foo<B>` 可以赋值给 `Foo<A | B>`,但反过来却不行。这是因为反向赋值通常不安全(尽管在这个特定的 `Foo<T>` 定义中是安全的):`Foo<A> | Foo<B>` 指的是要么总是输出 `A`,要么总是输出 `B` 的东西,而 `Foo<A | B>` 指的是可能输出 `A` 或 `B` 混合的东西。
正如我们之前看到的,联合类型并不总是被分发,有时这很不方便。它们也会通过多个 `return` 语句和条件表达式(`condition ? new Foo(42) : new Foo("AH")`)自然产生。虽然我们总可以通过类型断言或类型边界强制转换为 `Foo<A | B>`,但有时将这种转换作为方法的一部分会更方便。
我在为 TypeScript 编写一个小型结果库时遇到了这种情况:
```typescript
const x = condition ? okay(42) : fail(Error("error!")) // Result<number> | Result<Error>
const y = x.map(it => it + 69)
```
我们希望 `y` 具有“简洁”的类型 `Result<number | Error>`,意味着它要么持有数字要么持有错误,而不是 `Result<number> | Result<Error>`,后者意思相同但可读性差。
下面是如何实现:
```typescript
type GetT<F> = F extends Foo<T> ? T : never
class Foo<T> {
constructor (readonly value: T) {}
copy<This extends Foo<any>>(this: This): Foo<GetT<This>> {
return new Foo(this.value as GetT<This>)
}
}
declare const r: Foo<A> | Foo<B>
const x = r.copy() // 类型为 `Foo<A | B>`
```
我们捕获接收者的类型到 `This`(在这个例子中它是一个联合)。然后使用提取类型 `GetT` 来恢复 `T` 的值。由于条件类型会在联合上分发,`GetT<This>` 产生 `A | B`,我们得到了想要的结果。
## 总结
到此为止。现在你已经知道:
- 重载如何对联合参数进行分发(它们不进行分发,除了接收者)
- 条件类型如何在联合上分发(如果 `extends` 前面是裸露的类型变量,则会分发)
- 如何通过类型变量、联合类型和条件类型来模拟在联合上分发的重载
- 如何在方法内部通过使用提取类型来压缩同构联合(`Foo<A> | Foo<B>` 到 `Foo<A | B>`)
希望本文对你有帮助,下次再见!
相似文章
.NET(即C#)迎来联合类型
.NET 11 预览版在 C# 15 中引入了联合类型,这是一个期待已久的功能,用于处理可以是多种类型之一的数据,并新增了 'union' 关键字和模式匹配。
在Zig中利用Comptime实现标签联合子集
Mitchell Hashimoto 展示了如何利用 Zig 的 comptime 创建标签联合的子集类型,无需穷举处理即可实现编译时安全。
为什么 AI 智能体几乎都用 TypeScript 编写?
本文探讨了为何 TypeScript 已成为构建 AI 智能体及智能体框架的主流语言,并追问为何 Rust 或 C++ 等替代方案没有得到更广泛的应用。
Data types à la carte (2008)
本文提出了一种从独立组件组合数据类型和函数的技术,并将该方法扩展到结合自由单子,从而实现了对Haskell的IO单子的模块化结构。
TypeScript 7.0 Beta 发布
TypeScript 7.0 Beta 推出基于 Go 的全新编译器,速度约为 6.0 的 10 倍,同时保持完全语义兼容,并已在数百万行代码的实战中验证。