Read OSS

类型检查器: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 读取编译器选项,将常用的标志(strictNullChecksstrictFunctionTypesnoImplicitAny 等)预先计算并存入局部变量以便快速访问。随后,它构建内部基础设施——名称解析器、二元表达式检查器、节点构建器——并初始化全局符号表。

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 层级体系

Type 接口是基础,通过 TypeFlags 进行分类:

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 的处理逻辑往往可以提前退出,类型按复杂度从低到高排列:简单的原始类型排在前面,IndexedAccessConditional 等递归类型排在最后。

核心类型分类:

  • Intrinsic 类型stringnumberbooleanvoidundefinednullneveranyunknown——没有结构内容的单例类型
  • 字面量类型"hello"42true——携带具体值的类型
  • Object 类型:类、接口、匿名对象类型、映射类型、元组——通过 ObjectFlags 进一步细分(ClassInterfaceReferenceTupleAnonymousMapped 等)
  • Union/Intersection 类型T | UT & U——包含一组成员类型的数组
  • 类型参数:泛型 T,可带可选的 constraintdefault
  • 条件类型T extends U ? X : Y——对未解析的类型参数进行延迟求值
  • 模板字面量类型`hello${T}`——由字面量字符串片段与类型占位符组合而成
  • Indexed access 类型T[K]——根据 TK 惰性求值
  • 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

算法从引用处的流节点向后回溯:

  1. FlowAssignment:赋值类型成为 x 的新类型
  2. FlowCondition(TrueCondition):应用类型守卫。x !== null 会排除 nulltypeof x === "string" 会收窄为 stringx instanceof Foo 会收窄为 Foo
  3. FlowCondition(FalseCondition):应用反向守卫。
  4. FlowLabel(BranchLabel):沿每条前驱路径计算收窄类型,取所有结果的 union
  5. FlowLabel(LoopLabel):与 BranchLabel 类似,但需要迭代至不动点以处理循环。
  6. FlowCall:对于断言函数(asserts x is T),按声明进行收窄。

关键的性能优化:每个 FlowNode 都有一个 id 作为缓存键。检查器维护一个以 (flowNodeId, referenceSymbolId) 为键的流类型缓存。若没有这个缓存,对于含有大量分支的函数,收窄的计算复杂度很容易呈指数级增长。

提示: 如果遇到令人费解的收窄行为,第一步应该去查看流图。利用 node.flowNode(在 FlowContainer 节点上)沿前驱链回溯,就能看清检查器的视角。

Program 协调器

Program 是将一切整合在一起的顶层对象。createProgram() 接收根文件名列表和编译器选项,然后执行以下步骤:

  1. 解析文件:从根文件出发,通过模块解析发现所有传递性 import,构建完整的 SourceFile 对象集合。
  2. 管理检查器:检查器采用惰性创建策略(在首次调用 getSemanticDiagnosticsgetTypeChecker 时创建),并缓存复用。
  3. 提供 emitprogram.emit() 触发 emitter 流水线。
  4. 报告诊断:包括选项诊断、语法诊断(来自 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#L7403CompilerOptions 接口定义了所有编译器标志。其中许多标志会直接影响检查器的行为——strictNullChecks 决定 nullundefined 是否作为独立类型存在;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 如何在输入与输出之间保持映射关系。