类型检查器:54,000 行类型理论的实践
前置知识
- ›第 1–3 篇:架构、AST、Binder
- ›了解类型理论基础(子类型、型变、类型推断)
- ›熟悉 TypeScript 的高级类型特性(泛型、条件类型、映射类型、模板字面量类型)
类型检查器:54,000 行类型理论的实践
类型检查器是 TypeScript 名副其实的核心所在。src/compiler/checker.ts 大约有 54,400 行,是开源项目中体量最大的单文件之一。它实现了一套结构化类型系统,涵盖泛型、条件类型、映射类型、模板字面量类型、控制流收窄以及数十条特殊规则——整个系统由按需驱动的架构支撑,惰性计算类型并大量缓存结果。
本文将全面梳理这一系统:检查器如何初始化、所操作的 Type 层级体系、可赋值性与推断的核心算法、收窄如何消费 binder 的流图,以及 Program 协调器如何将一切串联起来。
createTypeChecker 与按需驱动架构
createTypeChecker(host) 是工厂函数。与 scanner、parser、binder 一样,它通过闭包持有数百个 var 局部变量:
export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
// Why var? It avoids TDZ checks in the runtime which can be costly.
var cancellationToken: CancellationToken | undefined;
var scanner: Scanner | undefined;
var Symbol = objectAllocator.getSymbolConstructor();
var Type = objectAllocator.getTypeConstructor();
var Signature = objectAllocator.getSignatureConstructor();
var typeCount = 0;
var symbolCount = 0;
var totalInstantiationCount = 0;
// ... hundreds more ...
检查器从 host 读取编译器选项,将常用的标志(strictNullChecks、strictFunctionTypes、noImplicitAny 等)预先计算并存入局部变量以便快速访问。随后,它构建内部基础设施——名称解析器、二元表达式检查器、节点构建器——并初始化全局符号表。
sequenceDiagram
participant P as Program
participant TC as TypeChecker
participant S as Symbol
participant SL as SymbolLinks
P->>TC: createTypeChecker(host)
Note over TC: Initialize 500+ var locals
P->>TC: getSemanticDiagnostics(sourceFile)
TC->>TC: checkSourceFile(sourceFile)
TC->>TC: checkExpression(node)
TC->>S: getSymbolOfNode(node)
TC->>SL: getSymbolLinks(symbol)
alt type not cached
TC->>TC: getTypeOfSymbol(symbol) → resolve type
TC->>SL: links.type = resolvedType
end
TC-->>P: Diagnostic[]
按需驱动是这套架构的关键所在。检查器不会在启动时急切地为程序中的每个符号计算类型,而是只在真正需要时才进行解析——例如检查某个表达式、生成某条诊断信息,或者语言服务请求某个补全项的类型时。每次解析结果都会缓存到 SymbolLinks 中,后续对同一符号类型的查询可以直接命中缓存。
这种惰性求值带来了实际的工程价值:修改文件中的某一行,不需要重新检查整个程序。当文件发生变化时,Program 只需丢弃缓存的检查器并创建新实例,新检查器只为真正用到的类型付出计算代价。
提示: 调试检查器时,大多数类型解析的入口是
getTypeOfSymbol(),在这里打断点可以观察类型如何按需计算。若要调试表达式类型检查,则从checkExpression()入手。
Type 层级体系
classDiagram
class Type {
+flags: TypeFlags
+id: TypeId
+symbol: Symbol
}
class IntrinsicType {
+intrinsicName: string
+objectFlags: ObjectFlags
}
class LiteralType {
+value: string | number | PseudoBigInt
}
class ObjectType {
+objectFlags: ObjectFlags
}
class UnionType {
+types: Type[]
}
class IntersectionType {
+types: Type[]
}
class TypeParameter {
+constraint: Type
+default: Type
}
class ConditionalType {
+checkType: Type
+extendsType: Type
+trueType: Type
+falseType: Type
}
Type <|-- IntrinsicType
Type <|-- LiteralType
Type <|-- ObjectType
Type <|-- UnionType
Type <|-- IntersectionType
Type <|-- TypeParameter
Type <|-- ConditionalType
LiteralType <|-- StringLiteralType
LiteralType <|-- NumberLiteralType
LiteralType <|-- BigIntLiteralType
TypeFlags 枚举的顺序经过精心设计——源码注释指出,排列顺序决定了 union 类型中各成员的位置。由于 union 的处理逻辑往往可以提前退出,类型按复杂度从低到高排列:简单的原始类型排在前面,IndexedAccess、Conditional 等递归类型排在最后。
核心类型分类:
- Intrinsic 类型:
string、number、boolean、void、undefined、null、never、any、unknown——没有结构内容的单例类型 - 字面量类型:
"hello"、42、true——携带具体值的类型 - Object 类型:类、接口、匿名对象类型、映射类型、元组——通过
ObjectFlags进一步细分(Class、Interface、Reference、Tuple、Anonymous、Mapped等) - Union/Intersection 类型:
T | U和T & U——包含一组成员类型的数组 - 类型参数:泛型
T,可带可选的constraint和default - 条件类型:
T extends U ? X : Y——对未解析的类型参数进行延迟求值 - 模板字面量类型:
`hello${T}`——由字面量字符串片段与类型占位符组合而成 - Indexed access 类型:
T[K]——根据T和K惰性求值 - Substitution 类型:实例化类型参数的内部记账机制
类型推断与可赋值性
可赋值性:结构兼容性
TypeScript 使用结构化类型系统——只要一个类型拥有至少相同的属性和签名,就认为它是兼容的。核心入口是 isTypeAssignableTo(source, target),它会调用 isTypeRelatedTo(),并传入具体的关系类型(可赋值性、可比较性或子类型关系)。
flowchart TD
Start["isTypeAssignableTo(source, target)"] --> Identity{"Same type?"}
Identity -->|Yes| OK["✓ Compatible"]
Identity -->|No| Special{"Special cases?<br/>any, never, unions"}
Special -->|"source = any"| OK
Special -->|"source = never"| OK
Special -->|"target = any"| OK
Special -->|"source = union"| EachSource["Each constituent<br/>must be assignable"]
Special -->|"target = union"| SomeTarget["At least one target<br/>constituent matches"]
Special -->|No special case| Structural["Structural comparison:<br/>Check each target property<br/>Check call signatures<br/>Check index signatures"]
Structural --> ExcessCheck{"Excess property<br/>check?"}
ExcessCheck -->|"Fresh object literal"| Excess["Check no extra<br/>properties"]
ExcessCheck -->|No| Done["Return result"]
这套算法处理了大量边界情况:
- Union 和 Intersection:source 是 union 时,要求每个成员都可赋值;target 是 union 时,只需任意一个成员匹配即可。
- 泛型:实例化的泛型根据型变方向比较类型参数(读位置协变,写位置逆变)。
- 多余属性检查:新鲜的对象字面量会接受更严格的检查——目标类型中不存在的属性会触发报错。
- 可辨识 union:对于 union 目标,检查器可以利用判别属性缩小需要对比的成员范围。
泛型的类型推断
调用泛型函数时若未显式传入类型参数,检查器会自动推断。推断算法为每个类型参数创建一个推断上下文,其中包含一个推断候选,然后将实参类型与形参类型逐一比对,在每个类型参数出现的位置收集候选类型。
以 function map<T, U>(arr: T[], fn: (x: T) => U): U[] 为例,调用方式为 map([1, 2], x => x.toString()),检查器会从数组参数推断出 T = number,从回调的返回类型推断出 U = string。推断按优先级顺序进行——来自实参的直接映射优先于来自返回类型的间接推断。
检查器中的控制流收窄
如第 3 篇所述,binder 会构建一张 FlowNode 图来建模程序的控制流。检查器通过 getFlowTypeOfReference() 消费这张图——该函数负责计算某个变量在代码特定位置的收窄类型。
flowchart BT
Use["Reference to x"] --> FA["FlowAssignment<br/>x = getValue()"]
FA --> FC["FlowCondition<br/>(TrueCondition)<br/>x !== null"]
FC --> FL["FlowLabel<br/>(merge point)"]
FL --> Start["FlowStart"]
style Use fill:#f9f,stroke:#333
style FC fill:#bbf,stroke:#333
算法从引用处的流节点向后回溯:
- FlowAssignment:赋值类型成为
x的新类型 - FlowCondition(TrueCondition):应用类型守卫。
x !== null会排除null;typeof x === "string"会收窄为string;x instanceof Foo会收窄为Foo。 - FlowCondition(FalseCondition):应用反向守卫。
- FlowLabel(BranchLabel):沿每条前驱路径计算收窄类型,取所有结果的 union。
- FlowLabel(LoopLabel):与 BranchLabel 类似,但需要迭代至不动点以处理循环。
- FlowCall:对于断言函数(
asserts x is T),按声明进行收窄。
关键的性能优化:每个 FlowNode 都有一个 id 作为缓存键。检查器维护一个以 (flowNodeId, referenceSymbolId) 为键的流类型缓存。若没有这个缓存,对于含有大量分支的函数,收窄的计算复杂度很容易呈指数级增长。
提示: 如果遇到令人费解的收窄行为,第一步应该去查看流图。利用
node.flowNode(在FlowContainer节点上)沿前驱链回溯,就能看清检查器的视角。
Program 协调器
Program 是将一切整合在一起的顶层对象。createProgram() 接收根文件名列表和编译器选项,然后执行以下步骤:
- 解析文件:从根文件出发,通过模块解析发现所有传递性 import,构建完整的
SourceFile对象集合。 - 管理检查器:检查器采用惰性创建策略(在首次调用
getSemanticDiagnostics或getTypeChecker时创建),并缓存复用。 - 提供 emit:
program.emit()触发 emitter 流水线。 - 报告诊断:包括选项诊断、语法诊断(来自 parser)、语义诊断(来自检查器)和声明诊断(来自声明 emit)。
Program 接口暴露了以下方法:
export interface Program extends ScriptReferenceHost {
getRootFileNames(): readonly string[];
getSourceFiles(): readonly SourceFile[];
emit(...): EmitResult;
getOptionsDiagnostics(): readonly Diagnostic[];
getSyntacticDiagnostics(sourceFile?): readonly DiagnosticWithLocation[];
getSemanticDiagnostics(sourceFile?): readonly Diagnostic[];
getTypeChecker(): TypeChecker;
// ...
}
位于 src/compiler/types.ts#L7403 的 CompilerOptions 接口定义了所有编译器标志。其中许多标志会直接影响检查器的行为——strictNullChecks 决定 null 和 undefined 是否作为独立类型存在;strictFunctionTypes 控制函数参数的型变方向;exactOptionalPropertyTypes 控制可选属性是否包含 undefined,等等。
Program 还承担着模块解析这一关键职责——给定一个 import 说明符,它会根据配置的解析策略(Node.js、classic、bundler)将其解析为文件路径。解析结果按文件缓存在 resolvedModules 映射中,模块解析缓存可在多个 Program 实例间复用,从而提升增量构建性能。
expressionToTypeNode 桥接模块
在检查器与 emitter 的边界处,有一个值得关注的基础设施组件:src/compiler/expressionToTypeNode.ts。这个模块将内部的 Type 对象转换回 AST 的 TypeNode 语法——是检查器通常所做的逆向操作。它对于声明文件的生成(第 5 篇)至关重要:源码中没有显式类型标注的推断类型,必须在 .d.ts 输出中以类型语法的形式具体呈现出来。
下一步
检查器产出了类型、诊断信息和已解析的类型信息,编译器随即进入输出阶段。第 5 篇将跟随 emitter 流水线——一系列 AST 到 AST 的 transformer 如何逐步将 TypeScript 语法降级为 JavaScript、声明文件如何生成,以及 source map 如何在输入与输出之间保持映射关系。