用集合论真正理解 TypeScript 类型
学 TypeScript 的时候,大部分人是这么学联合类型和交叉类型的:
|表示”或”,&表示”并且把两边的属性合并在一起”。
这套说法背下来能应付日常写代码,但一旦碰到稍微复杂点的场景——比如为什么两个字符串字面量类型相交会变成 never,为什么条件类型作用在联合类型上会自动”分裂”——就开始懵了。
其实 TypeScript 的类型系统背后,有一套非常朴素的数学模型:集合论。一旦你把”类型”理解成”一堆值的集合”,前面那些看起来反直觉的规则,全都会变得理所当然。这篇文章就带你换个视角,重新认识一遍 TypeScript 的类型系统。
一、类型的本质:一个装着”合法值”的筐
先抛开语法,问一个最朴素的问题:一个类型到底是什么?
答案是:一个类型,就是所有满足这个类型的值组成的集合。
举几个例子感受一下:
boolean类型,对应的集合是{true, false}——一个只有两个元素的有限集合。number类型,对应的集合是所有能表示的数字组成的集合——非常大,但依然是个集合。- 字面量类型
"success",对应的集合是{"success"}——只有一个元素的集合,专业说法叫”单例集合”。
再看两个特殊类型:
never:对应空集∅。没有任何值属于never,所以任何用到never的地方都进不去(比如一个永远不会执行到的return)。unknown:对应全集。它包含了这个世界上所有可能的值,任何东西都可以赋值给unknown。
把这几个对应关系记在脑子里,后面的规则会自动”推导”出来,不需要死记硬背。
二、联合类型 |:集合的并集
type Status = "success" | "error" | "loading";
这行代码在做的事情,就是把三个单元素集合 {"success"}、{"error"}、{"loading"} 取并集,拼成一个含三个元素的大集合 {"success", "error", "loading"}。
再看一个更”大”的例子:
type ID = string | number;
这里是把 string 这个巨大的集合和 number 这个巨大的集合并在一起,得到一个更大的集合——覆盖了所有字符串和所有数字。
说白了,| 就是日常说的”或者”:”要么是这个,要么是那个”,谁都行,全都算数。
三、交叉类型 &:集合的交集(这里最容易搞错)
大部分人对交叉类型的直觉理解是:A & B 就是”把 A 和 B 的字段拼在一起”。
interface Name { name: string }
interface Age { age: number }
type Person = Name & Age; // { name: string; age: number }
看起来确实像是”合并字段”,但这只是表面现象。真正发生的事情是求交集:一个值要同时”属于” Name 的集合,又”属于” Age 的集合,就必须同时满足两边的条件——既要有 name 字段,又要有 age 字段。所以”字段合并”其实是”同时满足两个集合条件”的结果,而不是原因。
这个视角的好处是,它能立刻解释一个新手常见的困惑:
type Impossible = string & number; // never
为什么两个基础类型相交会得到 never?因为不存在任何一个值,既是字符串又是数字。string 集合和 number 集合根本没有交集,交集是空集,对应到类型系统里,就是 never。
一旦用集合论理解,这个结果就不再是”奇怪的边界情况”,而是必然的数学结论。
四、子类型:其实就是子集
TypeScript 判断”这个值能不能赋值给那个类型”,本质上是在问一句话:
这个值所在的集合,是不是目标类型集合的子集?
来看一个例子:
interface Animal { name: string }
interface Dog { name: string; breed: string }
let d: Dog = { name: "旺财", breed: "柴犬" };
let a: Animal = d; // 合法
Dog 要求的条件比 Animal 更多(多了 breed),所以满足 Dog 条件的值,数量上一定比满足 Animal 条件的值要少——Dog 集合是 Animal 集合的子集。反过来,一只 Animal 却不一定是 Dog(可能没有品种信息)。
这也是为什么”条件更多、约束更严”的类型反而能赋值给”条件更少、约束更宽松”的类型——听起来有点反直觉,但从集合的角度看非常自然:子集里的每个元素,天然也是父集合的元素。这正是 TypeScript “结构化子类型(Structural Typing)”规则背后的数学解释。
五、extends、泛型约束、条件类型:问的都是同一个问题
理解了子集关系,你会发现 TypeScript 里好几个看起来不相关的语法,其实都在问同一句话——”A 是不是 B 的子集”:
// 泛型约束:T 必须是 string 集合的子集
function double<T extends string>(x: T) { ... }
// 条件类型:判断 T 是不是 string 的子集
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true,因为 {"hello"} 是 string 的子集
type B = IsString<number>; // false,number 集合不是 string 集合的子集
顺带一提一个容易踩坑的细节:如果 T 是一个联合类型,条件类型会把联合类型拆开,对每个成员分别做子集判断,再把结果重新并起来,这叫”分布式条件类型”:
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// 结果是 string[] | number[],而不是 (string | number)[]
原因也很直白:对于并集来说,”整体是不是子集”和”逐个元素是不是子集,再把结果拼起来”,在数学上是等价的。TypeScript 干脆就用后一种方式实现(如果不想要这个行为,把 T 用元组包一层,比如 [T] extends [any] ? ... : ...,就能阻止这种拆分)。
六、类型收窄:往集合里做过滤
写代码时经常见到这种模式:
function f(x: string | number) {
if (typeof x === "string") {
// 这里 x 被收窄成了 string
}
}
用集合论看,这就是一次过滤:x 原本属于 string | number 这个较大的集合,typeof x === "string" 这个判断把它筛到了一个更小的子集 string 里。类型收窄,做的事情跟你写 filter 函数没有本质区别,只不过过滤的是”类型”而不是”数组”。
七、可辨识联合:把一个大集合切成互不重叠的几块
interface Circle { kind: "circle"; radius: number }
interface Square { kind: "square"; side: number }
type Shape = Circle | Square;
这里的 Shape 是一个大集合,被 kind 字段切成了两个互不相交的子集:kind 为 "circle" 的一块,和 kind 为 "square" 的一块。TypeScript 常说的”穷尽性检查(exhaustiveness check)”,本质上就是在验证:这几块子集的并集,是不是刚好把 Shape 这个大集合完整覆盖了,不多也不少。
八、any:一个不讲道理的例外
前面所有规则都很和谐,直到 any 出现。
按理说,any 应该表现得像”全集”,可以装下所有值。但它的实际行为是:任何类型都能赋值给 any,any 也能反过来赋值给任何类型。 这在集合论里其实是矛盾的——一个真正的全集,只能”包含”别人,不可能反过来被随便一个子集”倒灌”回去。
所以准确地说,any 并不是一个真正遵守集合规则的类型,它更像是 TypeScript 主动开的一个”逃生舱口”:一旦用上它,编译器就直接放弃对这块代码做集合运算和类型检查。这也是为什么大家都建议少用 any——它不是”集合论世界”里的正常公民,而是绕开整个类型系统的后门。
小结
把整篇文章的对应关系放在一起看一遍:
| TypeScript 概念 | 集合论概念 |
|---|---|
| 类型 | 一堆值组成的集合 |
A | B |
并集 |
A & B |
交集 |
extends / 泛型约束 / 条件类型 |
子集判断 |
| 类型收窄 | 对集合做过滤 |
never |
空集 |
unknown |
全集 |
any |
不讲规则的例外 |
下次再写类型定义、看条件类型报错、或者纠结”这两个类型为什么能互相赋值”的时候,不妨在脑子里画一个维恩图。你会发现,很多以前靠死记硬背的规则,其实一直都摆在那里,等着被看懂而已。