TypeScript 相关联的联合类型问题与 Distributive Object Types 模式
2025年11月3日 · 5450 字
写 TypeScript 久了,你肯定遇到过这种情况:代码逻辑没问题,跑起来也正常,但编译器就是报错,死活不让你过。
更气人的是,这类错误往往出现在你精心设计的代码里——你心里清楚这段代码绝对安全,但编译器不这么认为。
今天, 我们来回顾一下 TypeScript 社区中一个非常经典的问题 -- Issue #30581,也就是常被提到的 “相关联的联合类型(Correlated Unions)” 问题。
想象一个常见场景:你要设计一个事件系统,或者一个能处理多种数据类型(联合类型)的调度器。 在 JS 里这种写法太常见了——拿到数据的 Tag,派发给对应的 handler,简单粗暴,好用。
但一旦用 TypeScript 来写,事情就没那么顺利了。编译器会报错,说你可能把"错误的参数"传给了"错误的函数"。 问题在于,编译器似乎无法理解这些参数和函数之间其实是强关联的。
不过,这不是 TypeScript 的 Bug,而是结构化类型系统的先天短板。这个问题卡了社区很久。
直到 Anders Hejlsberg 在 PR #47109 中,修复了索引访问类型在映射类型中使用时存在的多个问题,并由此提出了“distributive object types”的实用模式,才让类型检查器能够正确保持联合类型成员之间的关联关系。
接下来,我们将通过具体的代码示例,逐步拆解这个问题: 它最初是如何表现的,为什么旧的写法无法通过类型检查,以及新的模式究竟是如何工作的。
相关联联合类型的困境
经典场景:异构数据处理器
假设我们需要构建一个能够处理不同类型(数字、字符串、布尔值)的记录系统。每条记录包含三个核心要素:
- kind:一个字符串字面量,作为类型及其区分的标签。
- v:实际的数据值。
- f:一个专门处理该数据的函数。
在 TypeScript 中,这样的结构通常会被建模为一个联合类型:
type UnionRecord =
| { kind: "n"; v: number; f: (v: number) => void }
| { kind: "s"; v: string; f: (v: string) => void }
| { kind: "b"; v: boolean; f: (v: boolean) => void };
这个定义本身并不复杂,语义也很明确:
- 如果是
"n",数据就是number,处理函数就接收number。 - 如果是
"s",数据就是string,处理函数就接收string。 - 如果是
"b",数据就是boolean,处理函数就接收boolean。
这三者是绑定的:kind 决定了 v 的类型,也决定了 f 的签名。换句话说,kind、v、f 之间一一对应,只要来自同一条记录,这层关系就不会变。
基于这个类型,我们希望实现一个通用的处理函数 processRecord。它的逻辑非常简单:
接收一条记录,并调用记录内部的处理函数,处理对应的数据值。
function processRecord(rec: UnionRecord) {
// 我们的意图:调用 rec 内部的函数 f,处理 rec 内部的值 v
rec.f(rec.v);
}
从直觉上看,这段代码没有任何问题。对于任意一条合法的 UnionRecord,f 都必然能够正确处理 v。
但是,TypeScript 编译器却给出了一个看似莫名其妙的错误:
Error: Argument of type 'string | number | boolean' is not assignable to parameter of type 'never'. Type 'string' is not assignable to type 'never'.
这报错看得人一脸懵:
明明是安全的调用,怎么就类型错误了?站在我们的角度,同一条记录里的 f 和 v 肯定是配套的,但 TypeScript 怎么就看不出来呢?
以及,rec.f 这个函数的参数类型又为什么会被推导成 never ?
编译器看到的世界
要理解这个错误,得从编译器的视角来看。
独立分析的问题
编译器分析属性时,不会把对象当成一个整体,而是拆开来一个个看。
-
分析
rec.f的类型: 由于rec是一个联合类型,rec.f自然也是一个联合类型。它可能是处理数字的函数,可能是处理字符串的函数,也可能是处理布尔值的函数: -
分析
rec.v的类型: 同理,rec.v也是一个联合类型:
- 尝试函数调用: 现在,编译器面对的是这样一个调用场景:它有一个函数集合
Type(rec.f),和一个参数集合Type(rec.v)。 为了保证类型安全,TypeScript 必须确保:无论rec.f最终是哪一个具体的函数,传入的参数都必须是合法的。
这意味着,传入的参数必须能够同时满足所有可能的函数签名。
- 参数必须能赋值给 number(为了满足第一个函数)。
- 参数必须能赋值给 string(为了满足第二个函数)。
- 参数必须能赋值给 boolean(为了满足第三个函数)。
在 TypeScript 中,这其实表现为函数参数的逆变。为了安全调用一个函数联合类型,参数类型必须是所有可能参数类型的交叉类型。
显然,没有任何一个值能同时是数字、字符串和布尔值。因此,这个交叉类型变成了 never。
结论:编译器认为,rec.f 这个函数集合,根本不接受任何实际存在的参数。所以它报错说:“你试图把 string | number | boolean 传给 never”。
丢失的上下文
问题不在推导逻辑,而在于编译器丢了上下文。
当我们写 rec.f(rec.v) 时,人类开发者的理解是:
f和v来自同一条记录。- 它们之间的对应关系,已经由
kind在类型定义中锁死。
但编译器在完成属性访问之后,已经无法保留这种关联关系。 它担心的是这样一种理论上的可能性:
rec.f实际上是处理string的函数(来自第二个联合成员)。rec.v实际上是number类型的值(来自第一个联合成员)。
虽然在 UnionRecord 的定义中,这种情况是不可能发生的(kind 锁死了 f 和 v 的对应关系),但在类型系统完成“拆解 → 合并”之后,这层约束已经消失了。编译器看到的只是两个独立的联合类型(一个函数联合与一个值联合),它无法确定这两个联合类型在运行时是否会发生错配,于是只能退回到最保守、也最安全的判断。
这就是 Issue #30581 的本质:TypeScript 在这一阶段,无法追踪联合类型中不同属性之间的相关性。
而我们要做的,其实就是想办法把这种相关性重新交还给类型系统。
传统解决方案的局限
在很长一段时间里,开发者只能用各种 workaround 绕过去。这些方案能跑,但要么丢了类型安全,要么维护起来很痛苦。
方案一:类型断言
最常见的做法是使用 as any 或显式类型断言,强行压制编译器的报错。
function processRecord(rec: UnionRecord) {
// Forced assertion: I know f and v are matched
// The semicolon prevents ASI issues
(rec.f as (v: any) => void)(rec.v);
}
这其实就是向类型系统投降。一旦 UnionRecord 的定义变了(比如 f 和 v 不再匹配),编译器不会给你任何警告,运行时炸了才知道。这种做法在大型工程里是巨大隐患。
方案二:控制流收窄
显式地检查 kind 字段,帮助编译器收窄类型范围。
function processRecord(rec: UnionRecord) {
switch (rec.kind) {
case "n":
// 在这个分支里,rec 被收窄为第一个成员
// rec.f 是 (v: number) => void, rec.v 是 number
rec.f(rec.v);
break;
case "s":
rec.f(rec.v);
break;
case "b":
rec.f(rec.v);
break;
}
}
在每一个分支中,rec 都会被收窄为联合类型中的某一个具体成员,类型检查自然可以通过。但这种做法虽然类型安全,但扩展性太差。
- 如果我们有 50 种类型,就需要写 50 个 case 分支。
- 所有的处理逻辑其实是完全一样的
(rec.f(rec.v)),我们却被迫为了迎合编译器而重复写 N 遍。 - 违反了 DRY(Don't Repeat Yourself)原则,增加了维护成本。
方案三:引入泛型约束
还有一类更高级的尝试,是希望通过泛型参数来捕获 rec 的具体形态。
function processRecord<T extends UnionRecord>(rec: T) {
rec.f(rec.v); // 依然报错!
}
即使引入了泛型,问题依旧。在泛型约束 <T extends UnionRecord> 内部,编译器只知道 T 是 UnionRecord 的子类型。当访问 rec.f 和 rec.v 时,它依然会按照联合类型的规则进行拆解和合并。
换句话说,泛型并没有阻止联合类型被展开分析,问题的根源并没有发生变化,最终仍然会回到“函数参数交叉为 never”的结论上。
小结
这三种方案看似各不相同,但本质上都没有真正解决问题:
- 类型断言绕开了类型系统。
- 控制流收窄牺牲了抽象能力。
- 泛型约束并未改变推导模型。
它们要么放弃安全性,要么放弃可维护性,但始终无法让编译器自然地理解联合成员之间的关联关系。
这问题困扰社区好几年。直到 Anders 大佬亲自下场,才算有了解法。
Anders Hejlsberg 与类型设计哲学
在给出最终的解决方案之前,我们先花点时间聊聊 TypeScript 的设计哲学——这有助于理解为什么问题会以这种方式被解决。
TypeScript 的设计风格,很大程度上是 Anders Hejlsberg 定的调。无论是早年的 Delphi,还是后来的 C#,Anders 始终坚持一种工程化、务实的类型设计哲学:类型系统的目标不是追求理论完美,而是服务于大规模工程实践。
C# 的泛型 vs TypeScript 的泛型
Anders 在设计 C# 的泛型时,采用的是具体化的策略。
在 C# 中,List<int> 和 List<string> 在运行时是不同的类型,各自拥有独立的类型信息与生成代码。也就是说,C# 的泛型在运行期是真实存在的,类型系统可以直接依赖运行时事实。
TypeScript 的前提条件则完全不同。 作为构建在 JavaScript 之上的类型系统,TypeScript 采用的是类型擦除模型:所有类型信息在运行时都会被移除。这决定了 TypeScript 的泛型只能存在于编译期,所有约束与推断都必须通过静态分析完成,而不能依赖任何运行时检查。
结构化类型系统的代价
除此之外,TypeScript 还选择了另一条与 C# 完全不同的道路。
C# 和 Java 这类语言用的是名义类型系统:只要名字不同,就是不同类型,类型关系由显式声明决定。而 TypeScript 选择的是结构化类型系统:只要结构一样,就是同一个类型,类型关系由形状决定。
结构化类型极大地提升了灵活性,也降低了使用门槛,但它带来的一个副作用是:类型之间的关联性很难被表达。
我们真正想表达的,其实是这样一句话:
存在某个类型
T,使得rec同时满足{ v: T, f: (x: T) => void }
Issue #30581 的本质,正是这种问题的集中体现。
这在类型理论中被称为存在类型(Existential Type)。
然而,在旧版 TypeScript 中,编译器并不具备表达这种“存在某个 T”的能力。它只能看到已经展开的联合类型成员,并在属性访问时将它们拆解成彼此独立的联合类型。
一旦相关性被拆解,后续的推断就不可避免地走向 never。
工程化的答案
从纯类型理论的角度看,这个问题并非无解。
引入依赖类型、路径依赖类型,或者更完整的存在量词表达能力,都可以在类型层面彻底解决这一问题。但 Anders 很清楚,这些方案意味着什么:
- 编译器复杂度显著上升
- 错误信息更难理解
- 学习成本急剧增加
- 与 JavaScript 的心智模型渐行渐远
这就跟 TypeScript 的初衷冲突了。TS 不想成为 Haskell 或 Scala,它的定位一直是:在不脱离 JS 语义的前提下,给开发者一个够用但不复杂的类型系统。
因此,Issue #30581 的解决思路并不是重写类型系统,而是在现有模型内修复推断流程的缺陷。PR #47109 所做的事情,正是如此:没有引入新的类型语法,没有暴露新的概念给用户,只是让编译器在特定场景下,不要过早地拆解联合类型成员之间的关联性。
PR #47109 的技术解构
PR #47109 引入了一个关键概念:通过泛型索引访问创建的分发式对象类型(Distributive Object Types created by generic indexed access)。听起来很拗口,但名字本身并不重要,重要的是它解决问题的方式:把类型之间的关联性前移到“生成阶段”。
核心思想:从结果回到源头
在旧的思路中,我们试图在已经形成的 UnionRecord(结果)上恢复字段之间的关联关系,但这在结构化类型系统中几乎不可行。
PR #47109 采取了相反的策略: 不在结果上做修补,而是先定义一个单一的类型来源,再通过泛型和映射规则生成所有派生类型。
这个源头就是一个简单的键值映射关系。
重构步骤详解
让我们用全新的模式重构代码。
第一步:定义基础映射表
首先,将所有类型关联关系抽离为一个纯映射接口:
interface RecordMap {
n: number;
s: string;
b: boolean;
}
RecordMap 不包含任何行为或逻辑,仅描述最基本的对应关系:
某个 key 对应某个 value 类型。
这一步的意义在于:让类型关联变成显式、可索引的结构。
第二步:从映射表到分发式联合类型
在拥有 RecordMap 之后,接下来的问题是:
如何从这份映射关系中,生成一个保持内部关联性的联合类型?, 也就是如何来生成 UnionRecord。
最直接的做法,是先定义一个泛型类型,用来表达单个 kind 对应的一条完整记录, 再手动枚举出联合类型 UnionRecord:
type RecordMap = { n: number; s: string; b: boolean };
type RecordType<K extends keyof RecordMap> = {
kind: K;
v: RecordMap[K];
f: (v: RecordMap[K]) => void;
};
type UnionRecord = RecordType<"n"> | RecordType<"s"> | RecordType<"b">;
// 等价于之前的:
type UnionRecord =
| { kind: "n"; v: number; f: (v: number) => void }
| { kind: "s"; v: string; f: (v: string) => void }
| { kind: "b"; v: boolean; f: (v: boolean) => void };
不过,手动为 RecordMap 中的每个键创建 RecordType<K> 的联合类型不太优雅, 扩展性也差。 我们可以通过映射类型 + 索引访问来自动化这一步。
type UnionRecord = {
[P in keyof RecordMap]: RecordType<P>;
}[keyof RecordMap];
这种将索引访问类型应用到一个映射类型上的模式,本质上是一种分发装置:
它可以把某个类型(这里是 RecordType<P>)分发到一个联合类型(这里是 keyof RecordMap)的每一个成员上。
我们甚至可以更进一步,让 UnionRecord 支持由任意 key 子集生成。
type UnionRecord<K extends keyof RecordMap = keyof RecordMap> = {
[P in K]: RecordType<P>;
}[K];
接下来,我们可以把 RecordType<K> 直接内联到 UnionRecord<K> 里。这样一来,用户就没法绕过分发式映射去构造记录类型了,最终得到这个定型写法:
type RecordMap = { n: number; s: string; b: boolean };
type UnionRecord<K extends keyof RecordMap = keyof RecordMap> = {
[P in K]: {
kind: P;
v: RecordMap[P];
f: (v: RecordMap[P]) => void;
};
}[K];
第三步:泛型函数的应用
现在,我们重写 processRecord 函数。
function processRecord<K extends keyof RecordMap>(rec: UnionRecord<K>) {
// ✅ 这里不再报错!
rec.f(rec.v);
}
declare const r1: UnionRecord<"n">; // { kind: 'n', v: number, f: (v: number) => void }
declare const r2: UnionRecord; // { kind: 'n', ... } | { kind: 's', ... } | { kind: 'b', ... }
processRecord(r1);
processRecord(r2);
processRecord({ kind: "n", v: 42, f: (v) => v.toExponential() });
而且,由于所有内容都是用 RecordMap 中的映射来表达的,因此可以在一个地方添加新的种类和数据类型,很好地符合 DRY 原则。
从编译器视角理解当前代码为何成立
之所以现在这段代码可以通过类型检查,关键在于 PR #47109 对类型推理方式做了调整。
当编译器检查 processRecord 的函数体时,它是在一个以 K 为泛型参数的上下文中进行分析的。
在这个过程中:
rec.f的类型是(v: RecordMap[K]) => voidrec.v的类型是RecordMap[K]- 编译器需要判断:
rec.v能否作为参数传给rec.f
在旧的推理策略中,如果 K 是联合类型,RecordMap[K] 会被提前展开成 number | string | boolean。
一旦展开,rec.f 和 rec.v 之间原本的对应关系就丢失了,检查自然无法通过。
PR #47109 的改变在于:
当编译器发现 rec 来自一种“通过泛型索引访问生成的联合类型”时,不再急着把 RecordMap[K] 展开成具体的联合,而是先保留它的原始形式。
在这种情况下,编译器只关心一件事:
rec.f 和 rec.v 中用到的 RecordMap[K] 是否指向同一个类型。
由于它们都来自同一个泛型参数 K,这个条件是成立的。
因此,只要 rec 本身是合法的 UnionRecord<K>,调用 rec.f(rec.v) 就可以被认为是安全的。
分发式对象类型详解
搞清楚这个模式为什么能 work,还得再深挖一下它背后的规则。
什么是分发性
在 TypeScript 中,“分发”通常出现在条件类型的上下文中。 例如:T extends U? X : Y。如果 T 是一个联合类型 A | B,那么结果会被自动分发为:(A extends U? X : Y) | (B extends U? X : Y)。也就是说,对联合类型的整体判断,会被分解为对每一个成员的独立判断。
PR #47109 将这种分发特性扩展到了索引访问类型。 当类型定义形如 { [P in K]:... }[K] 时,如果 K 是一个泛型参数,TypeScript 就会激活这种分发行为。这意味着,对该类型的任何操作,都可以视为对 K 的每一个可能成员分别进行操作,最后再将结果联合起来。
这种机制有效地将联合类型的整体操作拆解为了对每一个成员的独立操作,从而在每个独立操作的上下文中,类型是单一且确定的,完全避免了交叉类型为 never 的问题。
触发优化的必要条件
并不是所有的代码都能自动享受这个优化。要触发 PR #47109 的行为,必须遵循以下模式:
- 基础映射表: 必须存在一个定义键值关系的接口或对象类型。
- 映射类型: 必须使用
{ [P in K]:... }的语法。 - 索引访问: 必须紧接着使用
[K]进行取值。 - 泛型约束:使用该类型的函数必须声明泛型
<K extends keyof BaseMap>。 - 映射类型保持原样:映射过程中不改变键的可选性、readonly 等修饰符。
只要破坏了其中任何一环(比如在函数中直接写死 UnionRecord<keyof TypeMap> 而不是用泛型 K),编译器就认不出这个模式了,又会退回到旧的推导策略,该报的错还是会报。
延迟求值:真正起作用的关键
在编译器内部,通过这种模式定义的类型 RecordMap[K] 会被标记为"延迟解析"。即使 K 被约束为 "n" | "s",编译器也不会急着把 RecordMap[K] 展开成 number | string,而是先保留 RecordMap[K] 这个符号。
这么做的好处是:只要类型还保持符号形式,编译器就可以在单一 K 的上下文里验证类型关系,而不用同时考虑所有可能。
打个比方:
- 旧策略:编译器先把
K的所有可能值都列出来,再同时验证所有情况,很容易算不过来。 - 新策略:编译器选择先不展开,只验证"对任意一个合法的
K,这套映射关系是不是成立"。
这种延迟求值的思路,让原本难搞的相关联合类型,可以在更小、更确定的上下文中被正确处理。
结语
把"关联关系"集中到基础映射里,再用泛型把这层关联传递出去——这种模式让我们在 TypeScript 里也能写出类型安全的高度抽象代码,不用再靠类似于 as any 这种操作来糊弄编译器了。
说到底,这个模式的价值不在于炫技,而是让你在需要抽象的时候,不用跟编译器妥协。业务逻辑有对应关系,类型系统就应该能表达这层关系——这才是类型系统该干的事。