本节目标:把
tsc从一个黑盒命令拆成一个可以调用的库。读完你能用ts.createProgram加载整个项目、用forEachChild遍历语法树、用TypeChecker问出「这个符号到底是什么类型」,并把诊断渲染成人能读的文本。这是后续自定义 transformer、codemod 与 tsPlugin 的共同地基。
5.1 TypeScript Compiler API 入门
前面四章我们一直在和类型系统本身打交道:结构化兼容、类型擦除、条件类型、装饰器。它们都在回答同一个问题——「类型该怎么写」。从本章起视角翻转:我们把 TypeScript 当成一个可以被程序调用的库,去读它、改它、生成它。
npm install typescript 装下来的包里不只有 tsc 那个可执行文件。lib/typescript.js 导出了全部编译器内部对象:语法树、类型检查器、发射器、语言服务。你在编辑器里享受的补全与跳转,也是同一套 API 撑起来的。掌握它,等于拿到了 TypeScript 工具链的源码级入口。
5.1.1 为什么要越过 tsc
tsc 是一个「配置驱动」的命令行程序:你告诉它读哪些文件、输出到哪,它把结果吐出来。但真实工程里的需求往往不长这样:
| 需求 | tsc 能做吗 | Compiler API 能做吗 |
|---|---|---|
| 编译项目并检查类型 | 能 | 能 |
| 统计项目里所有导出的公共 API | 不能 | 能 |
| 根据 interface 生成 mock 数据 | 不能 | 能 |
| 编译期改写语法(自动埋点) | 不能 | 能(transformer,见下一节) |
| 给编辑器提供补全与跳转 | 不能 | 能(Language Service) |
一句话概括:tsc 是这套 API 的一个薄壳。当需求超出「编译」两个字,就得自己拿 API 组装流程。
5.1.2 三个入口,三种粒度
Compiler API 表面对象很多,实际只有三个入口值得先记住:
| 入口 | 创建方式 | 适用场景 | 代价 |
|---|---|---|---|
SourceFile | ts.createSourceFile | 单文件语法分析、格式化、轻量改写 | 无类型信息 |
Program | ts.createProgram | 全项目类型检查、发射、transformer | 需要完整编译一次 |
Language Service | ts.createLanguageService | 增量、按需、编辑器交互 | 需维护快照与版本号 |
三者是层层加码的关系:SourceFile 只做语法解析(scanner + parser),Program 在其上建立符号表与类型图,Language Service 再把 Program 包成可增量查询的服务。选错入口最典型的症状是——明明只想要语法结构,却扛了一整个项目的类型检查开销。
如果只想读源码的「形状」而完全不关心类型,createSourceFile 就够了:
import ts from "typescript";
import { readFileSync } from "node:fs";
const code = readFileSync("./src/index.ts", "utf8");
const sf = ts.createSourceFile(
"index.ts", // 文件名,参与相对路径解析与诊断定位
code, // 源码文本
ts.ScriptTarget.ESNext, // 解析目标,影响可选链等语法的解析方式
true, // setParentNodes:是否给每个节点挂 parent 指针
ts.ScriptKind.TS // TS / TSX / JS / JSX,决定 <T> 是泛型还是 JSX
);
console.log(sf.statements.length); // 顶层语句数量,例如 7
console.log(sf.languageVersion); // 99(ScriptTarget.ESNext 的枚举值)
setParentNodes 这个参数很容易被忽略。传 false 时每个节点的 parent 都是 undefined,遍历时你无法从子节点回溯到父节点——很多写法会因此静默失效。除非明确只需要一次性向下遍历,否则一律传 true。
5.1.3 用 createProgram 加载真实项目
只要涉及类型,就必须走 Program。最省事的方式是让 API 自己去读 tsconfig.json:
import ts from "typescript";
const configPath = ts.findConfigFile("./", ts.sys.fileExists, "tsconfig.json");
if (!configPath) throw new Error("找不到 tsconfig.json");
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
"./" // 基准目录,决定 include 里相对路径的解析起点
);
const program = ts.createProgram(parsed.fileNames, parsed.options);
const checker = program.getTypeChecker();
这几行是几乎所有 TypeScript 工具的标准开场。它做的事和 tsc 启动时完全一样:定位配置、展开 include/exclude、套用 extends、把 files 与依赖图合并成待编译文件列表。
ts.sys 是编译器与宿主环境的接口层,封装了文件读写、目录遍历、换行符与大小写敏感性判断。想在内存里做虚拟文件系统(测试或在线 Playground),就自己实现一个 CompilerHost 把它替换掉:
const host = ts.createCompilerHost(parsed.options);
const originalRead = host.readFile;
host.readFile = (fileName) =>
fileName === "virtual.ts" ? "export const x = 1;" : originalRead(fileName);
5.1.4 遍历语法树
拿到 SourceFile 之后就是遍历。TypeScript 的 AST 不是数组而是对象树,每个节点有 kind、pos、end 与若干子节点字段。标准写法是 forEachChild:
function walk(node: ts.Node, depth = 0): void {
const indent = " ".repeat(depth);
console.log(`${indent}${ts.SyntaxKind[node.kind]}`);
ts.forEachChild(node, (child) => walk(child, depth + 1));
}
walk(sf);
这里有两个关键点:
- 用
ts.SyntaxKind[node.kind]打印名字。node.kind是个数字,直接打印只能看到254;反向查表才得到FunctionDeclaration这种可读名。 - 不要手写
node.statements.forEach(...)。 每种节点的子节点字段名都不同,只有forEachChild知道完整的遍历规则,漏掉某个字段就等于漏掉一部分 AST。
如果想按类型筛选,ts.isXxx 系列断言比 node.kind === ts.SyntaxKind.Xxx 更好用——它们带类型收窄,断言之后 node 会自动变成对应类型:
function collectFunctions(node: ts.Node, out: ts.FunctionDeclaration[] = []) {
if (ts.isFunctionDeclaration(node) && node.name) {
out.push(node); // 此处 node 已被收窄为 ts.FunctionDeclaration
}
ts.forEachChild(node, (child) => collectFunctions(child, out));
return out;
}
const fns = collectFunctions(sf);
console.log(fns.map((f) => f.name!.text)); // ['main', 'helper']
拿到节点之后,取它的源码文本有三种方式,区别很重要:
node.getText(); // 需要 parent 指针,返回含前后 trivia 的文本
sf.text.slice(node.pos, node.end); // 裸切片,不依赖 parent,但包含前导注释
node.getStart(sf); // 去掉前导 trivia 的起始偏移,常与 getText 配合
getText() 依赖 parent 指针向上找到 SourceFile,这正是 setParentNodes 必须为 true 的第二个理由。如果只想要干净的表达式文本,getStart(sf) 配 end 的组合更稳。
5.1.5 TypeChecker:把节点翻译成类型
遍历只能看到「写了什么」,TypeChecker 才能回答「这意味着什么」。它是 Compiler API 里最值钱的部分,也是自己在 AST 上写脚本永远无法替代的部分:
const type = checker.getTypeAtLocation(node);
console.log(checker.typeToString(type)); // 'string | undefined'
// 打印完整类型,不省略长联合
const full = checker.typeToString(type, node, ts.TypeFormatFlags.NoTruncation);
console.log(full); // 'string | undefined | null | { id: number; }'
常用的几个查询:
| 方法 | 输入 | 得到 |
|---|---|---|
getTypeAtLocation | 任意节点 | 该表达式的推导类型 |
getSymbolAtLocation | 标识符节点 | 声明处的符号 |
getFullyQualifiedName | 符号 | 含命名空间的完整名 |
getExportsOfModule | 模块符号 | 模块导出的全部符号 |
getSignaturesOfType | 类型 | 函数 / 构造签名列表 |
「列出包的全部导出并打印类型」这个需求,用上面几个方法就能拼出来:
function dumpExports(program: ts.Program, entry: string) {
const checker = program.getTypeChecker();
const sf = program.getSourceFile(entry);
if (!sf) return;
const moduleSymbol = checker.getSymbolAtLocation(sf);
if (!moduleSymbol) return;
for (const sym of checker.getExportsOfModule(moduleSymbol)) {
const type = checker.getTypeOfSymbolAtLocation(sym, sf);
console.log(`${sym.name}: ${checker.typeToString(type)}`);
}
}
对 sf 直接取符号有个前提:entry 必须是一个模块(含 import/export)。如果是全局脚本,取到的符号是全局作用域,输出会变成一堆内置声明,容易让人误以为脚本写错了。
5.1.6 诊断:把错误变成可读文本
类型检查的产物是 Diagnostic,它是结构化的:文件、起始位置、长度、错误码、消息模板。要打印成人能读的样子,得自己格式化:
const diagnostics = ts.getPreEmitDiagnostics(program);
for (const d of diagnostics) {
if (d.file && d.start !== undefined) {
const { line, character } = d.file.getLineAndCharacterOfPosition(d.start);
const msg = ts.flattenDiagnosticMessageText(d.messageText, "\n");
console.log(`${d.file.fileName}:${line + 1}:${character + 1} - ${msg}`);
} else {
console.log(ts.flattenDiagnosticMessageText(d.messageText, "\n"));
}
}
messageText 可能是字符串,也可能是嵌套的 DiagnosticMessageChain(错误信息里常常要嵌入子消息,例如「参数类型不匹配,因为 X 不能赋给 Y」)。必须用 flattenDiagnosticMessageText 展平,直接 console.log 会打印成 [object Object]。
想省事就直接用官方格式化器:
const host: ts.FormatDiagnosticsHost = {
getCanonicalFileName: (f) => f,
getCurrentDirectory: () => process.cwd(),
getNewLine: () => "\n",
};
console.log(ts.formatDiagnosticsWithColorAndContext(diagnostics, host));
Diagnostic 还带一个 code 字段,它就是你在编辑器里看到的 TS2345 这类编号:
console.log(d.code, ts.DiagnosticCategory[d.category]);
// 2345 'Error' —— 与 tsc 输出里的 TS2345 完全一致
用 code 做分类处理比匹配消息文本可靠得多,因为消息会随版本本地化或被改写,编号不会。
5.1.7 增量与性能
createProgram 每次都是全量:重新解析、重新绑定、重新检查。项目一大(数千文件),单次就要几秒。做 watch 类工具时必须换成增量入口:
const host = ts.createIncrementalCompilerHost(parsed.options);
const builder = ts.createEmitAndSemanticDiagnosticsBuilderProgram(
parsed.fileNames,
parsed.options,
host
);
// 下次只要传入上一次的 builder,就只重算受影响的文件
const next = builder.getProgram();
console.log(next.getSourceFiles().length);
BuilderProgram 会记住上一次的 Program 与 .tsbuildinfo,下一轮只重算依赖发生变化的文件。TypeScript 自身的 --watch 与 --incremental 走的都是这条路。性能测量与火焰图的分析方法,见 性能剖析与火焰图
。
5.1.8 常见坑与错误信息
| 现象 | 原因 | 处理 |
|---|---|---|
Cannot read properties of undefined (reading 'parent') | createSourceFile 没开 setParentNodes | 第四个参数传 true |
打印节点名得到数字 254 | 直接用了 node.kind | 反查 ts.SyntaxKind[node.kind] |
诊断消息是 [object Object] | 未展平消息链 | flattenDiagnosticMessageText |
所有类型都推成 any | 文件没进 Program,或走了 createSourceFile | 改用 Program + getTypeAtLocation |
| 升级 TS 后脚本报错 | 部分 API 未标记为稳定 | 锁定 typescript 版本并写回归测试 |
最后一条值得单独强调:Compiler API 里大量函数没有 @public 标记,官方不保证跨小版本兼容。工程化使用时要像对待内部 API 一样——锁版本、写回归测试、升级时先跑测试再合并。
5.1.9 与类型系统的连接点
Compiler API 不是孤立的工具,它是理解前四章内容的显微镜。想知道「结构化兼容到底比较了哪几个成员」,可以直接在 checker 上问两个类型是否兼容;想验证类型擦除的边界,可以对比 SourceFile 与发射后的 JS;想量化类型实例化的开销,--generateTrace 拿到的就是这套 API 的输出。
延伸阅读:想了解把 Program 包成编辑器服务的这一层,可以读 TypeScript 语言服务与编辑器插件
;想补本节跳过的 scanner / parser 细节,可以读 编译器语法解析
与 词法分析
两篇专题。
5.1.10 串起来:一个完整的导出清单脚本
把前面的片段拼成一个可以直接运行的脚本:
import ts from "typescript";
const entry = process.argv[2];
const configPath = ts.findConfigFile("./", ts.sys.fileExists, "tsconfig.json")!;
const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
const { options, fileNames } = ts.parseJsonConfigFileContent(config, ts.sys, "./");
const program = ts.createProgram(fileNames, options);
const checker = program.getTypeChecker();
const sf = program.getSourceFile(entry);
if (!sf) throw new Error(`文件不在编译范围内:${entry}`);
const mod = checker.getSymbolAtLocation(sf);
if (!mod) throw new Error(`不是模块(缺少 import/export):${entry}`);
const rows = checker.getExportsOfModule(mod).map((sym) => {
const t = checker.getTypeOfSymbolAtLocation(sym, sf);
return { name: sym.name, type: checker.typeToString(t) };
});
console.table(rows);
用 npx tsx dump-exports.ts ./src/index.ts 运行,会得到一张表,每行是一个导出名与它的类型字符串。这个脚本只有二十来行,却已经覆盖了本节的全部要点:读配置、建 Program、取符号、查类型、格式化输出。
小结
- Compiler API 是
tsc的底座:SourceFile管语法、Program管类型、Language Service管增量交互。 - 三个必背动作:
createProgram建项目、forEachChild遍历、TypeChecker问类型。 - 诊断是结构化数据,
flattenDiagnosticMessageText与formatDiagnosticsWithColorAndContext负责把它变成人话。 - 涉及类型就绕不开
Program;只读语法形状才用createSourceFile,且记得开setParentNodes。 - 这套 API 不承诺稳定,锁版本加回归测试是工程化底线。
有了「读」的能力,下一节我们讲「改」——把自定义 transformer 挂进发射管线,在编译期重写整棵语法树。
阅读导航:上一节:4.3 AOP 与运行时类型信息 · 下一节:5.2 自定义 transformer 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。