类型系统基石:再识 TypeScript 的结构化类型世界
第 1 章 类型系统基石:再识 TypeScript 的结构化类型世界
类型系统的核心命题可以浓缩为一个问题:两个类型之间的赋值关系,由什么决定? 在 TypeScript 中,答案并非"它们是否在源代码中通过 `extends` 或 `implements` 显式关联",而是"它们的结构是否匹配"。这种被称为结构化类型(Structural Typing)的范式,与 Java、C#、Swift 等主流语言所采用的名义类型(Nominal Typing)形成鲜明对照。它既是 TypeScript 灵活性的来源,也是无数看似"显然正确"的代码会突然编译失败的根源。本章将逐层剖析这套系统的底层规则——形式化的赋值兼容性(assignability)、子类型方向性、变型性(variance)、类型层级以及对象字面量的特殊待遇——为你后续理解泛型、条件类型与映射类型打下坐标系。
1.1 从一个反直觉的赋值错误说起
先看一段让初学者怀疑人生的代码:
```typescript interface Point2D { x: number; y: number; }
interface Point3D { x: number; y: number; z: number; }
const p3d: Point3D = { x: 1, y: 2, z: 3 }; const p2d: Point2D = p3d; // ✅ 编译通过 console.log(p2d.x, p2d.y); // 1 2
const p2d2: Point2D = { x: 10, y: 20 }; const p3d2: Point3D = p2d2; // ❌ Property 'z' is missing in type 'Point2D' ```
第一次见到这种代码的人通常会有两个直觉:第一种直觉是"`Point3D` 既然包含 `Point2D` 的所有字段,那么反过来 `Point2D` 应该可以赋值给 `Point3D`";第二种直觉是"虽然结构上不完全一样,但既然 TypeScript 都能推导出 `p2d.x` 是 `number`,为什么不报错"。这两种直觉都是错的。TypeScript 不关心类型之间的"继承语义",只关心目标类型期望的成员是否在源类型上全部存在。
再看一个更具迷惑性的例子:
```typescript interface Cat { name: string; meow(): void; }
interface Dog { name: string; bark(): void; }
const dog: Dog = { name: "旺财", bark: () => {} }; const cat: Cat = dog; // ✅ 居然编译通过 cat.meow(); // ❌ Runtime TypeError: dog.meow is not a function ```
`Cat` 和 `Dog` 在源代码中没有任何显式关联,连共同的基接口都没有,但 `Dog` 仍然可以赋值给 `Cat`。这是因为 `Dog` 结构上满足 `Cat` 的全部要求(拥有 `name: string` 和 `meow()` 方法)。然而运行时会因为 `Dog` 实例实际上没有 `meow` 方法而崩溃——这正是结构化类型世界最戏剧化的张力:类型系统承诺的接口契约,与运行时实际持有的对象能力,并不必然一致。
理解了这层张力,本章剩下的内容才有意义:我们需要精确刻画 TypeScript 究竟在什么样的形式化规则下做出"通过"或"拒绝"的判断,以及这些规则在边界处如何"双标"。
1.2 结构化类型与名义类型:两种世界的范式分野
名义类型的代表是 Java、C#、C++、Rust、Swift。在这些语言里,类型的关系由声明决定:
```java // Java 中即使两个类结构完全相同,也不可以互换 class Cat { String name; void meow() {} } class Dog { String name; void meow() {} } Cat c = new Dog(); // ❌ incompatible types ```
名义类型要求赋值双方必须存在显式的继承链(`extends`、`implements`)或显式的转换。这种规则的好处是类型关系封闭且可追溯——任何可以赋值的地方,编译器都能告诉你"为什么可以"。它的代价是灵活性差:鸭子类型(duck typing)在 JavaScript 中是天然习惯,但 Java 里你需要为每个外部结构手写适配层。
TypeScript 的设计目标是描述 JavaScript 的运行时行为。JavaScript 是一门动态语言,变量类型在运行时才确定,对象可以携带任意属性,方法可以在原型链上临时挂载。如果 TypeScript 采用名义类型,那么大量已经在 JavaScript 生态中广泛存在的"形状兼容即可"的代码会被无端拒绝。结构化类型正是对这一运行时事实的类型层映射。
但 TypeScript 并未将结构化执行到极致。运行时行为复杂,单纯的结构匹配会引入新的不一致——比如上一节的"鸭子分类器"陷阱。因此,TypeScript 在结构匹配之上又叠加了几层"补丁":
1. 对象字面量的多余属性检查(excess property checking):当直接给变量赋一个对象字面量时,TypeScript 会拒绝含有多余属性的对象,即使结构上"满足"目标类型。 2. 函数参数的双变(bivariance):在默认(`strictFunctionTypes: false`)模式下,函数参数既允许协变也允许逆变。这是为了兼容 JavaScript 的常见写法(如把 `number | string` 的函数传给期望 `number` 的参数位)。 3. 字面量类型的细化与拓宽:在某些上下文(如 `let` 声明、函数返回值)中,字面量会被拓宽为更宽的类型;在另一些上下文(如 `const` 声明、类型断言)中则保持字面量精度。
这三层补丁让 TypeScript 既保留了结构化的灵活性,又不至于让代码完全脱离工程可控的范围。后续章节会逐一深入,本节只需明确:结构化是底色,但并非全部。
1.3 赋值兼容性的形式化定义
TypeScript 官方手册用了大量篇幅描述赋值兼容性,但缺少一个统一的形式化表达。本节尝试给出一个精确且便于后续推导的版本。
定义(结构化赋值兼容): 给定两个类型 `S` 和 `T`,我们说 `S` 赋值兼容于 `T`(记作 $S <: T$),当且仅当满足以下条件之一:
1. 恒等规则:$S$ 与 $T$ 是同一个类型。 2. 任意类型穿透:$S$ 或 $T$ 中的任意一个是 `any`(包括 `any[]`、具体 `any` 联合等情况下的特殊情况)。 3. 未知类型吸收:$T$ 是 `unknown`,则任何 $S$ 都赋值兼容于 $T$。 4. 永不类型传染:$S$ 是 `never`,则 $S$ 赋值兼容于任何 $T$。 5. 原始类型:若 $S$ 与 $T$ 都是原始类型(如 `number`、`string`、`boolean`),要求 $S$ 的值集合是 $T$ 的值集合的子集。 6. 联合类型:$S$ 是联合类型 $S_1 \mid S_2 \mid \dots \mid S_n$,则要求对每个 $i$,$S_i <: T$。 7. 交叉类型:$T$ 是交叉类型 $T_1 \& T_2 \dots \& T_n$,则要求对每个 $i$,$S <: T_i$。 8. 对象类型(含接口、类实例、type alias 等):$S$ 必须拥有 $T$ 所声明的所有可访问成员,且每个成员的赋值方向遵循变型性规则(详见 1.5 节)。 9. 函数类型:参数与返回值各自遵循变型性规则。 10. 数组与元组:视为对象类型或函数类型的特例。
这个定义看似面面俱到,实则把决策拆成了三个层次:顶层规则(never、unknown、any 提供边界)、结构匹配(对象、函数的成员比对)、变型性(成员自身的赋值方向)。掌握这个三层结构后,任何具体判断都可以被拆解为这三个层次的递归求值。
例如:
```typescript type A = { x: number; y: number }; type B = { x: number };
const a: A = { x: 1, y: 2 }; const b: B = a; // A <: B?需要检查 B 的每个成员是否在 A 中存在 // B.x: number 在 A.x 中找到 ✅ // 因此 A 满足 B 的结构要求,赋值兼容 ```
再看一个反向判断:
```typescript const b2: B = { x: 1 }; const a2: A = b2; // B <: A?需要 A 的 y 在 B 中找到 // B 中没有 y ❌ 拒绝 ```
形式化定义的价值在于它揭示了判断的算法本质——任何复杂的类型兼容问题,最终都可以被规约为"目标类型期望的成员是否在源类型中找得到"这个朴素问题。
1.4 类型层级:从 never 到 any 的全景视图
将形式化定义中出现的特殊类型(never、字面量、原始类型、{}、object、unknown、any)按赋值方向排列,就得到 TypeScript 的类型层级。下面的图描绘了这张层级图的核心部分。
```mermaid flowchart BT Top["any(双向)"]:::top Unk["unknown(顶部)"]:::top Obj["object(非原始类型)"]:::mid Empty["{}(非 null / undefined)"]:::mid Str["string"]:::prim Num["number"]:::prim Bool["boolean"]:::prim Big["bigint"]:::prim Sym["symbol"]:::prim Lit["字面量类型\n1 / \"a\" / true"]:::lit Never["never(底部)"]:::bot
Never --> Lit Lit --> Str Lit --> Num Lit --> Bool Str --> Empty Num --> Empty Bool --> Empty Big --> Empty Sym --> Empty Obj --> Empty Empty --> Unk Unk --> Top Never -.-> Top ```
层级的方向是赋值方向:箭头从 `S` 指向 `T` 表示 $S <: T$。从 `never` 出发的箭头指向一切表示"never 是任何类型的子类型";从一切指向 `unknown` 表示"任何类型都是 unknown 的子类型";`any` 与 `never` 和 `unknown` 之间的虚线表示 `any` 跨越了层级的两端——它既可以赋值给任何类型,也可以从任何类型赋值得到(这正是 `any` 的"逃逸"特征)。
几个容易被忽视的细节:
1. `{}` 与 `object` 不是一回事。`{}` 表示"任何不是 `null` 或 `undefined` 的值",因此 `1`、`"a"`、`() => {}` 都可以赋给 `{}`;而 `object` 是"任何非原始类型的值",因此 `1` 不能赋给 `object`。在 `--strictNullChecks` 下,`null` 和 `undefined` 既不能赋给 `{}` 也不能赋给 `object`,它们只能赋给 `any` 与 `unknown`(以及开启了 `--strictNullChecks` 之前的所有可空位置)。 2. 字面量类型是"被细化的原始类型"。`1` 的值集合是 `{1}`,`number` 的值集合是所有 IEEE-754 浮点数。前者显然是后者的子集,因此 `1 <: number`。同理 `"hello" <: string`、`true <: boolean`。 3. 联合类型与交叉类型在层级中不具备独立位置,它们是组合算子,结果类型的层级位置由参与组合的具体类型决定。例如 `1 | 2` 的层级位置略高于 `1` 但低于 `number`,而 `{ a: 1 } & { b: 2 }` 的层级位置略高于 `{ a: 1 }` 或 `{ b: 2 }` 任一者(成员更多,结构更"窄")。
记住这张图,后续讨论条件类型 `extends` 判断、infer 提取、协变逆变时,都可以借助"位置"和"方向"两个维度思考。
1.5 变型性四象限:协变、逆变、双变与不变
类型系统中,最容易让人绕晕的概念是变型性(variance)。它描述的是一个类型构造器(type constructor)在"参数类型变化时,构造结果之间的赋值方向如何变化"。在 TypeScript 中,这个概念主要体现在三个场景:函数参数、函数返回值、数组/可变容器的元素类型。
用形式化语言描述:
设 $C$ 是一个一元类型构造器(如 `Array<T>`、`() => T`)。给定两个类型 $A$、$B$ 且 $A <: B$,我们关心 $C[A]$ 与 $C[B]$ 之间的赋值关系:
- **协变(covariance)**:$C[A] <: C[B]$,方向与原方向一致。
- **逆变(contravariance)**:$C[B] <: C[A]$,方向反转。
- **双变(bivariance)**:两个方向都成立,即 $C[A] <: C[B]$ 且 $C[B] <: C[A]$。
- **不变(invariance)**:除非 $C[A] = C[B]$,否则双向都不成立。
四种变型性的形式化表达:
$$ \begin{aligned} \text{协变} &: A <: B \implies C[A] <: C[B] \\ \text{逆变} &: A <: B \implies C[B] <: C[A] \\ \text{双变} &: A <: B \implies (C[A] <: C[B]) \land (C[B] <: C[A]) \\ \text{不变} &: A <: B \implies (C[A] = C[B]) \end{aligned} $$
函数参数的逆变性
直觉上,函数参数应当是逆变的。考虑:
```typescript type Animal = { name: string }; type Dog = Animal & { breed: string };
type FeedAnimal = (a: Animal) => void; type FeedDog = (d: Dog) => void; ```
如果我们有"能处理任意 `Animal` 的回调",那么它一定能"处理 `Dog`"(因为 `Dog` 是 `Animal`),所以 `FeedAnimal <: FeedDog`。反过来,如果只有"专门处理 `Dog` 的回调",把它当作"处理 `Animal` 的回调"是不安全的——调用者可能传入 `Cat`,而该回调会调用 `dog.breed` 而崩溃。因此 `FeedDog` 不能赋值给 `FeedAnimal`。
这就是经典的 Liskov 替换原则(LSP)在类型层面的表达:函数参数在结构上必须是逆变的。
TypeScript 的双变与严格模式
然而 TypeScript 在默认配置下,函数参数实际上是双变的:
```typescript type FeedAnimal = (a: Animal) => void; type FeedDog = (d: Dog) => void;
const feedDog: FeedDog = (d) => console.log(d.breed); const feedAnimal: FeedAnimal = feedDog; // 默认配置下竟然通过!
const a: Animal = { name: "旺财" }; feedAnimal(a); // Runtime Error: Cannot read properties of undefined (reading 'breed') ```
为什么 TypeScript 不直接采用逆变?原因是历史包袱:在 `strictFunctionTypes` 引入之前,大量 JavaScript 迁移代码习惯于这种"宽松"的写法。完全采用逆变会让很多现有项目无法编译。开启 `"strictFunctionTypes": true` 后,参数严格按逆变处理。
注意:函数方法(即通过对象字面量或接口方法声明的函数)的参数仍然保持双变——这是另一个补丁,避免 `Array.prototype.push` 这类签名因为逆变而无法被赋值兼容。
数组的可变性
数组是变型性最容易踩坑的地方:
```typescript type Animal = { name: string }; type Dog = Animal & { breed: string };
const dogs: Dog[] = []; const animals: Animal[] = dogs; // 协变:Animal[] <- Dog[] animals.push({ name: "咪咪" }); // 没有 breed,运行时不报错 // 但 dogs 数组里混入了一只"非 Dog" const d = dogs[0]; d.breed; // 💥 Runtime Error ```
如果数组按协变处理,那么可变数组就是不安全的——往 `animals` 里推入普通