类型系统全景图:TypeScript 类型宇宙导览
第 1 章:类型系统全景图:TypeScript 类型宇宙导览
类型系统并非"附加在 JavaScript 之上的语法糖",而是一套独立的、形式化的、对运行时行为施加约束的静态分析框架。理解这一框架,是掌握 TypeScript 高级类型编程的起点。本章不从"如何写一个 interface"开始,而从"TypeScript 的类型系统究竟是什么"这一根本问题切入,沿着结构化类型(Structural Typing)→ 类型关系的形式化语义 → 拓宽与收窄的双向运动 → 声明文件与类型擦除 → 编译器架构 → 工程搭建的脉络,建立读者对 TypeScript 类型宇宙的宏观坐标系。
1.1 从血缘认证到形态契约:结构化类型的崛起
在 Java、Go、Rust 等主流静态类型语言中,两个类型是否兼容取决于它们的"声明血统"——即使两个类的字段与方法完全相同,只要它们没有显式的 `extends` / `implements` 关系,编译器就拒绝将一个视为另一个的子类型。这种基于类型名(type identity) 的子类型判定规则被称为名义子类型(Nominal Subtyping)。
```java // Java:名义子类型 class Cat { void meow() {} } class Dog { void meow() {} }
Cat c = new Dog(); // ❌ Compile error: incompatible types // 即使 Cat 与 Dog 的"形状"完全一致,类型名不同即不可赋值 ```
名义子类型的优势在于其语义刚性:每个类型都是唯一的、可追溯的、被名字锚定的身份。编译器可以基于类型名做精确的访问控制、虚函数表(vtable)派发、以及反射查询。但这种刚性也是其代价:它要求开发者显式声明继承或实现关系,否则即使两个类型在结构上等价,也无法互换。这种"血缘认证"模式在领域封闭、由单一组织维护的大型工程中表现良好,却在面向第三方库自由组合的 JavaScript 生态中水土不服。
TypeScript 选择了一条截然相反的道路——结构化类型(Structural Typing),又称鸭子类型(Duck Typing) 的静态化。其核心信条可以用一句话概括:
> 如果一个值的"形状(shape)"满足类型所要求的形态契约,那么这个值就是该类型的一个合法实例,类型名无关紧要。
```typescript // TypeScript:结构化类型 interface Point { x: number; y: number; }
const p = { x: 1, y: 2, z: 3 }; // 多出的 z 字段不构成障碍 const point: Point = p; // ✅ 合法:p 的形状"至少"满足 Point
class Vec2 { constructor(public x: number, public y: number) {} } const v = new Vec2(1, 2); const q: Point = v; // ✅ 合法:Vec2 实例的形状满足 Point ```
这里的判定逻辑不是"v 是不是 Vec2",而是"v 的形状是不是包含 Point 所要求的所有成员"。这就是所谓的形态子类型(Shape Subtyping)。
1.1.1 为什么 TypeScript 选择结构化类型
这一设计选择并非偶然,而是 TypeScript 团队对 JavaScript 生态深度适配的结果。JavaScript 自诞生之初就没有类的概念,ES5 时代对象通过字面量与原型链组合而成,ES6 引入的 `class` 关键字本质上是语法糖,对运行时行为不做强制约束。JavaScript 的对象天然是开放结构(open struct),可以随时添加或删除属性。TypeScript 的设计者——特别是主导者 Anders Hejlsberg(C# 与 Delphi 的设计者)——深刻意识到:要让 TS 无缝接入海量已存在的 JS 代码,类型系统必须接受 JS 的"形状自由"。
结构化类型带来三大工程收益:
1. 零成本互操作:JS 库无需任何改造即可被 TS 项目消费,类型兼容与否完全由形状判定。 2. 渐进式类型化(Gradual Typing):开发者可以从 `any` 开始,逐步为代码补充精确类型,系统保持可用。 3. 组合优于继承:结构化类型鼓励基于能力(capability)而非血统(pedigree)的编程。
但结构化类型并非没有代价。其最著名的"陷阱"是意外的结构兼容——两个语义无关的类型因巧合拥有相同形状而互换,引发运行期 bug。TypeScript 通过品牌类型(Branded Types)、私有字段(Private Fields)、以及严格模式下的多余属性检查(Excess Property Checks)等机制缓解这一问题。
1.1.2 形态子类型的形式化定义
设类型 $T$ 是其成员集合 $M(T) = \{m_1, m_2, \ldots, m_n\}$ 的有限映射,其中每个 $m_i$ 拥有名字与类型。对于类型 $A$ 与类型 $B$,结构化子类型关系 $\prec$ 定义为:
$$A \prec B \iff \forall (n, t) \in M(B): (n, t') \in M(A) \land t' \prec t$$
即:$A$ 是 $B$ 的子类型,当且仅当 $A$ 拥有 $B$ 所要求的所有成员,且对应成员的类型满足子类型关系。这一定义可递归展开到函数、数组、泛型等所有复合类型。
注意:结构化子类型是宽度子类型(Width Subtyping),即"成员更丰富的类型是成员较少的类型的子类型"。`{ x, y, z }` 是 `{ x, y }` 的子类型,但反过来不成立。这就是为何上例中多出 `z` 字段的 `p` 可以赋值给 `Point`。
1.1.3 结构化类型的"严格"边界
TypeScript 并非 100% 纯粹的鸭子类型。它在结构化判定的基础上,引入了若干"名义"补丁,以避免语义混淆:
- **类成员的 `private`/`protected`**:通过 `private` 修饰的成员,仅当两个类的同名 `private` 来自**同一个声明**时,才被视为兼容。这实质上在结构化判定中嵌入了一个"血统判定"维度。
- **类实例的"原类"检查**:即使形状一致,`A` 类的实例也不能赋值给 `B` 类(除非显式继承),因为类的"身份"会出现在子类型判定中。
- **空对象类型 `{}`**:与 `Object` 不同,`{}` 表示"任何非 `null`/`undefined` 的值",结构化判定不再适用。
这些边界划定了 TypeScript 在"结构自由"与"语义刚性"之间的折中点。后续章节讨论品牌类型与辨识联合(Discriminated Unions)时,会再次回到这些边界。
1.2 类型关系的四层语义:兼容、赋值、子类型、超类型
TypeScript 文档与编译器源码中频繁出现四个看似可互换、实则语义不同的术语:类型兼容性(Type Compatibility)、赋值性(Assignability)、子类型(Subtyping) 与超类型(Supertyping)。混淆它们是初学者乃至有经验的开发者常见的认知盲区。本节给出这四个概念的形式化定义,并澄清它们的差异与交集。
1.2.1 类型兼容性与赋值性:编译器层面的判定
类型兼容性(Type Compatibility) 是最广义的关系。TypeScript 编译器内部以一个名为 `isTypeAssignableTo` 的函数实现赋值性判定——即"值 `v` 是否能被赋给声明为类型 `T` 的位置"。其核心规则几乎就是结构化子类型判定 + 一组特殊豁免(如函数参数的双向协变(Bivariance)、字面量的类型拓宽等)。
赋值性与子类型不等价。最显著的反例是函数的参数:TypeScript 在检查函数赋值时,默认采用严格函数类型(Strict Function Types)——函数参数是逆变(Contravariant) 的;而在检查对象属性赋值时,参数是协变(Covariant) 的;TS 2.x 之前甚至是双向协变。换言之,赋值性是一种"工程化松弛"的兼容性,而子类型是"形式化严格"的兼容性。
```typescript // 赋值性 ≠ 子类型 type Animal = { name: string }; type Dog = { name: string; bark(): void };
const a: Animal = { name: "Rex" }; const d: Dog = { ...a, bark: () => {} };
// 反向赋值不成立,因为 Dog 要求 bark() const back: Dog = a; // ❌ Property 'bark' is missing ```
1.2.2 子类型与超类型:数学意义上的方向
子类型(Subtype) 与超类型(Supertype) 是一对反向关系。若类型 $S$ 的每个值都是类型 $T$ 的值,则 $S$ 是 $T$ 的子类型,记作 $S <: T$;反之 $T$ 是 $S$ 的超类型。直觉上,子类型是"更具体",超类型是"更宽泛"。
$$\text{Subtype}(S, T) \iff \forall v: S \Rightarrow v \text{ is also a } T$$
子类型关系具有自反性(Reflexive)($S <: S$)、传递性(Transitive)($S <: T \land T <: U \Rightarrow S <: U$)与反对称性(Antisymmetric)($S <: T \land T <: S \Rightarrow S = T$),构成一个偏序集(Poset)。
TypeScript 中常见子类型关系包括:
- `{ x: number }` 是 `{ x: number; y: number }` 的子类型(宽度子类型)
- `1` 是 `number` 的子类型(字面量子类型)
- `number[]` 是 `(number | string)[]` 的子类型(数组协变)
- `() => number` 是 `() => number | string` 的子类型(返回值协变)
1.2.3 协变、逆变与不变:类型构造器视角
复合类型(数组、函数、Promise 等)相对于其参数类型的"子类型方向"问题,是类型论的经典话题。设有类型构造器 $F$,若 $S <: T$ 蕴含 $F<S> <: F<T>$,则 $F$ 是协变(Covariant) 的;若蕴含方向反转,则 $F$ 是逆变(Contravariant) 的;若两者皆不蕴含,则 $F$ 是不变(Invariant) 的。
TypeScript 的默认行为(严格函数类型开启时):
| 构造器 | 协变/逆变 | 说明 | |--------|----------|------| | `Array<T>`、`Promise<T>`、`ReadonlyArray<T>` | 协变 | 元素可向下兼容 | | `T => R` 的参数 `T` | 逆变 | 参数类型在函数赋值中反转 | | `T => R` 的返回值 `R` | 协变 | 返回值类型在函数赋值中保持方向 | | `keyof`、`Readonly<T>` | 协变 | | | `Record<K, V>` | 部分不变 | `K` 协变,`V` 协变,但索引签名有特殊规则 |
这一矩阵是后续章节讨论映射类型与条件类型时的基石。
1.2.4 一个统一的视角
从编译器实现角度,可将四者关系视为同心圆:
``` 外层 ─────────────── 内层 [ 类型兼容性 ] ← 编译器实际检查的"宽松版" └─ [ 赋值性 ] ← 源码中 = / 参数传递 / 返回值检查 └─ [ 子类型 ] ← 形式化、严格、含协变/逆变规则 └─ [ 同一类型 ] ← 绝对等价(sameType) ```
实践中,当读者遇到"为什么这个赋值能通过"的疑问时,应按"兼容性 → 赋值性 → 子类型"的顺序排查。TS 编译器的报错信息(如 `Type 'A' is not assignable to type 'B'`)精准反映了"赋值性"层级。
1.3 类型的双向运动:拓宽与收窄
类型并非一成不变。在 TypeScript 的静态分析视角下,类型的"宽度"会随代码上下文发生两种方向的运动:拓宽(Widening) 与收窄(Narrowing)。理解这两种运动,是阅读编译器行为、编写精确类型代码的基础。
1.3.1 类型拓宽:声明时的隐式扩张
考虑以下代码:
```typescript let x = 1; // x 的类型是什么? const y = 2; // y 的类型是什么? ```
直觉上,`x` 与 `y` 都"看起来"是数字 1 与 2。TypeScript 中,`y` 的类型是字面量 `2`,但 `x` 的类型是 `number`——前者比后者更"窄"。这一现象叫做字面量拓宽(Literal Widening):当一个字面量被赋给一个 `let`/`var` 声明或非字面量位置时,TS 自动将其推断为该字面量所属的基类型。
拓宽规则受三方面影响:
1. 声明方式:`const` 声明保持字面量类型;`let`/`var` 声明拓宽。 2. 上下文位置:若声明位置已经是宽类型(如函数参数),则进一步放宽。 3. 显式标注:声明时给出类型注解,直接确定类型,跳过推断。
```typescript const a = "hello"; // 类型: "hello" let b = "hello"; // 类型: string let c: "hello" = "hello"; // 类型: "hello"
function f(s: string) {} // 参数 s: string f("world"); // 实参位置 "world" 被拓宽为 string ```
更精细的规则涉及常量上下文(const context)。当字面量出现在以下场景时,TS 不进行拓宽:
- 对象/数组字面量被 `as const` 标注
- 字符串字面量出现在模板字面量类型中
- 字面量被传入标注为字面量类型的参数
- 出现在 `const` 类型参数位置
```typescript const arr = [1, 2, 3]; // 类型: number[] const arr2 = [1, 2, 3] as const; // 类型: readonly [1, 2, 3]
const obj = { kind: "circle" }; // 类型: { kind: string } const obj2 = { kind: "circle" } as const; // 类型: { readonly kind: "circle" } ```
`as const` 是 TypeScript 提供给开发者的"反拓宽"工具,本质上是要求编译器将字面量与对象/数组视为不可变的最小类型。它是字面量类型、辨识联合、模板字面量类型等高级特性的前置条件。
1.3.2 类型收窄:控制流的精确追踪
与拓宽相对,类型收窄(Type Narrowing) 是 TypeScript 在控制流中根据上下文信息逐步缩小类型的过程。它是 TS "控制流类型分析(Control Flow Analysis, CFA)" 能力的核心体现。
```typescript function print(value: string | number) { if (typeof value === "string") { // 此处 value 类型被收窄为 string console.log(value.toUpperCase()); } else { // 此处 value 类型被收窄为 number console.log(value.toFixed(2)); } } ```
TS 支持多种收窄触发条件:
| 触发器 | 收窄机制 | 示例 | |--------|---------|------| | `typeof` 检查 | 原始类型收窄 | `typeof x === "string"` | | `instanceof` 检查 | 类实例收窄 | `x instanceof Date` | | `in` 操作符 | 属性存在性收窄 | `"foo" in obj` | | 等值比较 | 字面量收窄 | `kind === "circle"` | | 真值检查 | 排除 `null`/`undefined`/`0`/`""` | `if (x) { ... }` | | 辨识字段 | 辨识联合收窄 | `if (shape.kind === "rect") { ... }` | | 类型谓词 | 用户自定义收窄 | `function isCat(x: any): x is Cat { ... }` | | 断言函数 | 收窄并抛错 | `function assertDefined(x: T): asserts x is NonNullable<T>` | | `as` / `<>` 断言 | 强制收窄 | `(x as string)` |
控制流分析的核心数据结构是类型收窄栈(type narrowing stack),编译器在每个分支的结束处合并(join)所有可能的类型——这是后续章节讨论分布式条件类型时的重要前置。
1.3.3 拓宽与收窄的辩证
拓宽与收窄并非两个独立机制,而是类型系统对**值的存在形