《TypeScript高级编程》5.1 TypeScript Compiler API 入门

本节把 tsc 拆成一个可编程的库。先用 ts.createSourceFile 做单文件语法解析,再用 ts.createProgram 加载整个项目,随后用 forEachChild 遍历语法树、用 TypeChecker 查询每个节点的推导类型,最后把结构化诊断渲染成可读报错。读完你能写出统计项目导出符号并打印类型的小脚本。

本节目标:把 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 表面对象很多,实际只有三个入口值得先记住:

入口创建方式适用场景代价
SourceFilets.createSourceFile单文件语法分析、格式化、轻量改写无类型信息
Programts.createProgram全项目类型检查、发射、transformer需要完整编译一次
Language Servicets.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);

这里有两个关键点:

  1. 用 ts.SyntaxKind[node.kind] 打印名字。 node.kind 是个数字,直接打印只能看到 254;反向查表才得到 FunctionDeclaration 这种可读名。
  2. 不要手写 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes