这节在地图里是"钻进编译器内部"的第一站。tsc 不是一步到位,而是五个阶段串起来:扫描、解析、类型检查、转换、发射。知道每个阶段干什么,你才能解释"为什么类型错误不影响 .js 产出""为什么有些报错来自 checker 而非 parser"。
把字符流切成词法单元(tokens):关键字、标识符、运算符、字面量。
// 源码:const x: number = 1; // 扫描得到 tokens:const | x | : | number | = | 1 | ;
这一步只认"词",不认"语法对不对"。即使后面类型错了,扫描也能正常完成。
把 tokens 按语法规则组装成抽象语法树(AST)。语法错误(如少了分号、括号不配对)在这里报。
// 语法错误示例 function f( { // 报错:解析阶段发现括号未闭合
解析产出的是"未带类型信息"的 AST,AST 是后面所有阶段操作的核心数据结构。
Checker 遍历 AST,结合 lib.d.ts 和你的声明,逐节点推断与比对类型。书里所有的"契约判定"都发生在这里。
// 这类错误来自 checker,而非语法 const n: number = "str"; // 类型错误:string 不能赋给 number
输入输出:n 的赋值在 checker 阶段被标红,但注意——类型错误不阻止 .js 发射(除非开了 noEmitOnError)。这是新手常困惑的点:为什么明明一堆红,还是能跑出 .js。默认 tsc 只告警,照常产出。
把 TS 的 AST 转成目标版本/模块的 AST,比如把 class 转成 ES5 的函数写法、把 import 转成 require。
// 源码 class 字段 class A { x = 1; } // 转换后(target ES5 简化)变成构造函数里 this.x = 1
这一步同时剥离类型标注——类型信息在此彻底离开 AST,后续不再存在。
把转换后的 AST 打印成最终 .js/.d.ts 文本,写出磁盘。
npx tsc # 读 src/*.ts → 经五阶段 → 写 dist/*.js + dist/*.d.ts
这张图把五个阶段与"类型信息何时消失"标出来:

# 想要"有类型错就别出文件",CI 里用: npx tsc --noEmit # 只检查,零产物,错即非零退出 # 或 npx tsc --noEmitOnError # 检查并转换,但出错不写 .js
背景:CI 误以为"出 .js 了就是过了"。操作:改用 --noEmit 或 --noEmitOnError。结果:类型错直接阻断。解读:理解发射阶段独立,才知道该用哪个开关卡质量。
大型项目每次都走完整五阶段太慢。tsc 用 .tsbuildinfo 记录"哪些文件、依赖了哪些符号",重跑时只重查受影响的文件。
npx tsc --incremental # 首次全量,生成 node_modules/.tmp/tsconfig.tsbuildinfo # 之后只重查改动文件及其下游,其余从缓存读
背景:单体仓改一个文件,全量检查可能几十秒。操作:开 incremental(或项目引用的 tsc -b)。结果:改动小则检查秒回。解读:增量本质是"跨次运行复用第三、四阶段结果",它不改变五个阶段的顺序,只是跳过未变文件的昂贵检查——这是第九章提速的根本机制。
.d.ts 从哪来你写的类型标注在"转换阶段"被剥掉,但 .d.ts 是另一路产物——它在检查阶段后,由 emitter 把"公开的类型形状"单独打印成声明文件。
// 源码 export interface User { id: number; name: string; } export function greet(u: User): string { return u.name; } // 发射出的 index.d.ts // export interface User { id: number; name: string; } // export declare function greet(u: User): string;
背景:库要给别人用,消费方需要类型但不该看你的实现。操作:开 declaration: true。结果:类型形状被单独发射成 .d.ts,实现留在 .js。解读:.d.ts 是"类型信息在发射阶段的一次重生"——源码里被剥掉的类型,在这里以纯声明形式重新落地,供下游编译器消费。
五个阶段里,类型检查(第三、四阶段协作)占绝大部分耗时,因为它要做"全程序"的符号解析与递归推断,而非逐文件孤立处理。
典型耗时占比(大型项目): - 扫描 + 解析:10%~20% - 类型检查 + 转换:70%~85% - 发射:5%~10%
背景:很多人以为"慢在转译",其实慢在检查。操作:优化方向应指向减少检查负担(详见第九章:skipLibCheck、缩小 include、增量)。结果:转译再快也救不了检查瓶颈。解读:理解了热点在检查,就不会盲目换更快的转译器来"提速类型检查"——那是治标,检查本身才是标。
开发时用 tsc --noEmit 只查类型(快、反馈准),用 esbuild 单独转译(见第四章)。发射交给专门的打包器,比让 tsc 又查又转更高效,也更好定位问题出自哪个阶段。
发射阶段的产出可以是纯声明,这对库作者很关键——消费方只需要类型,不需要你的实现细节。
# 只出 .d.ts,不出 .js npx tsc --declaration --emitDeclarationOnly --outDir dist/types
输入输出:源文件里的 class Point { x: number } 在 dist/types 变成 declare class Point { x: number; },类型留在磁盘、实现被剥掉。这正是转换阶段"剥离类型信息"的结果在发射时的体现——你拿到的是契约,不是代码。
学会看报错能判断问题归属,省去瞎猜。
// 解析阶段报错(语法):"} expected." / "';' expected." function f( { // 括号不闭合,parser 直接红 // 检查阶段报错(类型):"Type 'X' is not assignable to type 'Y'.(TS2322)" const n: number = "x"; // checker 红,但 .js 仍能出(默认) // 发射阶段报错(配置):"Cannot write file ... because it would overwrite" // → outDir 配错,emitter 写盘冲突
背景:新手常分不清"语法错"和"类型错"。操作:看报错前缀与错误码。结果:语法错(parser)必须在写对结构前修;类型错(checker)可暂时出文件;写盘错(emitter)是配置问题。解读:把错误对应到五阶段,能精准决定"该改代码、改类型、还是改配置"。
接口声明合并是 TS 特有行为,发生在解析产出 AST 后、检查阶段前,由 binder 把同名声明并成一个符号。
interface User { id: number; } interface User { name: string; } // 同名,被合并 // 等价于 interface User { id:number; name:string; } const u: User = { id: 1, name: "x" }; // 两字段都要
输入输出:两个 interface User 合并成一个,缺任一字段都报错。注意这仅对 interface 生效,type 别名同名会直接冲突。理解"合并发生在检查前",能解释很多"我明明分开声明却能一起用"的现象。
noEmitOnError⚠️ 别看到红色报错还能出 .js 就以为 tsc 坏了。默认行为就是"检查报错但照常发射",这是设计使然;要阻断产物得显式加 --noEmit 或 --noEmitOnError。
💡 把"类型检查"和"代码发射"拆成两个命令,CI 用 tsc --noEmit 卡错误、构建用打包器出产物,问题出在哪个阶段一目了然。