Compiler API:用 ts.createProgram 写自己的类型检查工具
第28章:Compiler API:用 ts.createProgram 写自己的类型检查工具
理解Compiler API是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用用 ts.createProgram 写自己的类型检查工具?关键的设计决策和陷阱是什么?
读完本章你将理解:
- Compiler API 暴露了什么
- 环境搭建
- 遍历 AST
Level 1 · 你需要知道的(1-3年经验)
Compiler API 暴露了什么
TypeScript 的编译器不是一个黑盒。typescript npm 包导出了完整的编译器 API,让你可以:
- 读取和遍历 AST:语法树上的每个节点都有精确的类型
- 查询类型信息:给定任意 AST 节点,获取其 TypeScript 类型
- 诊断信息:获取类型错误、语法错误
- 代码转换(Transformer):修改 AST 并输出新代码
- 语言服务(Language Service):驱动编辑器的补全、跳转、重构
这比 ESLint 的 AST 分析强大得多——ESLint 只看语法,Compiler API 让你访问完整的类型信息。你可以问:"这个变量的类型是什么?"、"这个函数调用的返回类型是什么?"、"这个接口被哪些类实现了?"
环境搭建
npm install typescript
# typescript 包本身就包含编译器 API,不需要额外安装
// 基础结构
import ts from "typescript";
const program = ts.createProgram(
["src/index.ts"], // 入口文件
{ // CompilerOptions
strict: true,
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.CommonJS,
}
);
const checker = program.getTypeChecker();
const sourceFiles = program.getSourceFiles();
ts.createProgram 做的事和 tsc 编译器完全一样——读取 tsconfig.json(可选),解析所有导入,构建完整的类型图。
遍历 AST
基础遍历
import ts from "typescript";
function walk(node: ts.Node): void {
// 处理当前节点
console.log(ts.SyntaxKind[node.kind], node.getText().slice(0, 50));
// 遍历所有子节点
ts.forEachChild(node, walk);
}
const program = ts.createProgram(["src/index.ts"], { strict: true });
const sourceFile = program.getSourceFile("src/index.ts")!;
walk(sourceFile);
ts.SyntaxKind 是枚举,包含 TypeScript 所有语法节点的类型名称:FunctionDeclaration、VariableStatement、CallExpression 等。
访问者模式(更实用)
function visit(node: ts.Node, visitor: (node: ts.Node) => void): void {
visitor(node);
ts.forEachChild(node, (child) => visit(child, visitor));
}
// 找出所有函数声明
const functions: ts.FunctionDeclaration[] = [];
visit(sourceFile, (node) => {
if (ts.isFunctionDeclaration(node)) {
functions.push(node);
}
});
console.log(functions.map((f) => f.name?.text));
ts.isFunctionDeclaration、ts.isVariableDeclaration 等谓词函数由编译器 API 提供,并且会正确缩窄节点类型。
获取类型信息
这是 Compiler API 区别于普通 AST 分析的核心能力:
const checker = program.getTypeChecker();
visit(sourceFile, (node) => {
if (ts.isCallExpression(node)) {
const signature = checker.getResolvedSignature(node);
if (signature) {
const returnType = checker.getReturnTypeOfSignature(signature);
console.log(
"Call at",
node.getStart(),
"returns",
checker.typeToString(returnType)
);
}
}
});
checker.typeToString(type) 把内部类型对象转成人类可读的字符串,就像你在 IDE hover 时看到的那样。
实战工具 1:找出所有返回 any 的函数
这是一个真实有用的代码质量工具:
import ts from "typescript";
import * as path from "path";
interface AnyReturnFunction {
name: string;
file: string;
line: number;
}
function findFunctionsReturningAny(
rootFiles: string[],
options: ts.CompilerOptions = {}
): AnyReturnFunction[] {
const program = ts.createProgram(rootFiles, {
strict: true,
...options,
});
const checker = program.getTypeChecker();
const results: AnyReturnFunction[] = [];
function visitNode(node: ts.Node, sourceFile: ts.SourceFile): void {
const isFunctionLike =
ts.isFunctionDeclaration(node) ||
ts.isMethodDeclaration(node) ||
ts.isArrowFunction(node) ||
ts.isFunctionExpression(node);
if (isFunctionLike) {
const signature = checker.getSignatureFromDeclaration(
node as ts.SignatureDeclaration
);
if (signature) {
const returnType = checker.getReturnTypeOfSignature(signature);
// 检查返回类型是否包含 any(包括 Promise<any> 等)
const isAny = (type: ts.Type): boolean => {
if (type.flags & ts.TypeFlags.Any) return true;
// 检查泛型参数(处理 Promise<any> 的情况)
if (type.isUnionOrIntersection()) {
return type.types.some(isAny);
}
const typeArgs = checker.getTypeArguments(type as ts.TypeReference);
return typeArgs.some(isAny);
};
if (isAny(returnType)) {
const { line } = sourceFile.getLineAndCharacterOfPosition(
node.getStart()
);
// 获取函数名
let name = "<anonymous>";
if (
ts.isFunctionDeclaration(node) ||
ts.isMethodDeclaration(node)
) {
name = node.name?.getText(sourceFile) ?? "<anonymous>";
} else if (
ts.isVariableDeclaration(node.parent) &&
ts.isIdentifier(node.parent.name)
) {
name = node.parent.name.text;
}
results.push({
name,
file: path.relative(process.cwd(), sourceFile.fileName),
line: line + 1,
});
}
}
}
ts.forEachChild(node, (child) => visitNode(child, sourceFile));
}
for (const sourceFile of program.getSourceFiles()) {
// 跳过 node_modules 和 .d.ts 文件
if (
!sourceFile.isDeclarationFile &&
!sourceFile.fileName.includes("node_modules")
) {
visitNode(sourceFile, sourceFile);
}
}
return results;
}
// 使用
const anyFunctions = findFunctionsReturningAny(["src/index.ts"]);
anyFunctions.forEach(({ name, file, line }) => {
console.log(`${file}:${line} — ${name} returns any`);
});
Level 2 · 它是怎么运行的(3-5年经验)
实战工具 2:找出某个类型的所有使用位置
function findTypeUsages(
typeName: string,
rootFiles: string[]
): Array<{ file: string; line: number; context: string }> {
const program = ts.createProgram(rootFiles, { strict: true });
const checker = program.getTypeChecker();
const results: Array<{ file: string; line: number; context: string }> = [];
// 先找到目标类型的符号
let targetSymbol: ts.Symbol | undefined;
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile) continue;
ts.forEachChild(sourceFile, (node) => {
if (
(ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) &&
node.name.text === typeName
) {
targetSymbol = checker.getSymbolAtLocation(node.name);
}
});
}
if (!targetSymbol) {
console.warn(`Type "${typeName}" not found`);
return results;
}
// 遍历所有文件,找出使用了这个类型的节点
function visit(node: ts.Node, sourceFile: ts.SourceFile): void {
if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) {
const symbol = checker.getSymbolAtLocation(node.typeName);
if (symbol === targetSymbol) {
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
const lineText = sourceFile.text
.split("\n")[line]
.trim()
.slice(0, 80);
results.push({
file: path.relative(process.cwd(), sourceFile.fileName),
line: line + 1,
context: lineText,
});
}
}
ts.forEachChild(node, (child) => visit(child, sourceFile));
}
for (const sourceFile of program.getSourceFiles()) {
if (!sourceFile.isDeclarationFile && !sourceFile.fileName.includes("node_modules")) {
visit(sourceFile, sourceFile);
}
}
return results;
}
// 找出 UserProfile 类型的所有使用
findTypeUsages("UserProfile", ["src/index.ts"]).forEach(({ file, line, context }) => {
console.log(`${file}:${line} ${context}`);
});
实战工具 3:自定义 Lint 规则——检测生产文件中的 console.log
interface LintError {
file: string;
line: number;
column: number;
message: string;
}
function detectConsoleLogs(
rootFiles: string[],
excludePatterns: string[] = ["*.test.ts", "*.spec.ts"]
): LintError[] {
const program = ts.createProgram(rootFiles, { strict: true });
const errors: LintError[] = [];
function isExcluded(fileName: string): boolean {
return excludePatterns.some((pattern) => {
const regex = new RegExp(
pattern.replace(".", "\\.").replace("*", ".*")
);
return regex.test(fileName);
});
}
function visit(node: ts.Node, sourceFile: ts.SourceFile): void {
// 找 console.xxx() 调用
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === "console"
) {
const method = node.expression.name.text;
// 只报告 log、warn、error、debug
if (["log", "warn", "error", "debug", "info"].includes(method)) {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
node.getStart()
);
errors.push({
file: path.relative(process.cwd(), sourceFile.fileName),
line: line + 1,
column: character + 1,
message: `console.${method}() found in production code`,
});
}
}
ts.forEachChild(node, (child) => visit(child, sourceFile));
}
for (const sourceFile of program.getSourceFiles()) {
if (
!sourceFile.isDeclarationFile &&
!sourceFile.fileName.includes("node_modules") &&
!isExcluded(sourceFile.fileName)
) {
visit(sourceFile, sourceFile);
}
}
return errors;
}
// 使用
const lintErrors = detectConsoleLogs(["src/index.ts"]);
lintErrors.forEach(({ file, line, column, message }) => {
console.error(`${file}:${line}:${column} — ${message}`);
});
process.exitCode = lintErrors.length > 0 ? 1 : 0;
自定义 Transformer:AST 变换
Transformer 让你在编译时修改代码(类似 Babel 插件):
import ts from "typescript";
// 一个简单的 Transformer:把所有 console.log 调用替换成空语句
function removeConsoleLogs(): ts.TransformerFactory<ts.SourceFile> {
return (context: ts.TransformationContext) => {
return (sourceFile: ts.SourceFile) => {
function visit(node: ts.Node): ts.Node {
// 检测 console.log(...) 调用
if (
ts.isExpressionStatement(node) &&
ts.isCallExpression(node.expression) &&
ts.isPropertyAccessExpression(node.expression.expression) &&
ts.isIdentifier(node.expression.expression.expression) &&
node.expression.expression.expression.text === "console" &&
node.expression.expression.name.text === "log"
) {
// 返回空语句替换
return ts.factory.createEmptyStatement();
}
// 继续遍历子节点
return ts.visitEachChild(node, visit, context);
}
return ts.visitNode(sourceFile, visit) as ts.SourceFile;
};
};
}
// 应用 Transformer
const result = ts.transpileModule(
`
console.log("hello");
const x = 1 + 2;
console.log(x);
`,
{
compilerOptions: { target: ts.ScriptTarget.ES2020 },
transformers: {
before: [removeConsoleLogs()],
},
}
);
console.log(result.outputText);
// ; ← console.log("hello") 被替换为空语句
// const x = 1 + 2;
// ; ← console.log(x) 被替换为空语句
ts-morph:更友好的包装器
直接用 Compiler API 有些繁琐,ts-morph 是官方推荐的包装库:
import { Project, SyntaxKind } from "ts-morph";
const project = new Project({
tsConfigFilePath: "./tsconfig.json",
});
// 找出所有返回 any 的函数(比直接用 Compiler API 简洁得多)
for (const sourceFile of project.getSourceFiles()) {
if (sourceFile.isDeclarationFile()) continue;
for (const func of sourceFile.getFunctions()) {
const returnType = func.getReturnType();
if (returnType.isAny()) {
console.log(
`${sourceFile.getFilePath()}:${func.getStartLineNumber()} — ` +
`${func.getName() ?? "<anonymous>"} returns any`
);
}
}
}
// 重命名接口(安全重构)
const iface = project.getSourceFileOrThrow("src/types.ts")
.getInterfaceOrThrow("OldName");
iface.rename("NewName"); // 自动更新所有引用
// 保存所有修改
await project.save();
ts-morph 的优势:链式 API、自动内存管理、重构操作(rename、move)内置、错误信息更友好。
Language Service API:驱动编辑器功能
import ts from "typescript";
// Language Service 提供编辑器级别的功能
const serviceHost: ts.LanguageServiceHost = {
getScriptFileNames: () => ["src/index.ts"],
getScriptVersion: () => "0",
getScriptSnapshot: (fileName) => {
if (!ts.sys.fileExists(fileName)) return undefined;
return ts.ScriptSnapshot.fromString(ts.sys.readFile(fileName)!);
},
getCurrentDirectory: () => process.cwd(),
getCompilationSettings: () => ({ strict: true }),
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
fileExists: ts.sys.fileExists,
readFile: ts.sys.readFile,
readDirectory: ts.sys.readDirectory,
};
const languageService = ts.createLanguageService(serviceHost);
// 获取指定位置的补全列表(这就是编辑器自动补全的底层)
const completions = languageService.getCompletionsAtPosition(
"src/index.ts",
42, // 字符偏移量
undefined
);
// 获取类型诊断
const diagnostics = languageService.getSemanticDiagnostics("src/index.ts");
diagnostics.forEach((d) => {
const message = typeof d.messageText === "string"
? d.messageText
: d.messageText.messageText;
console.error(message);
});
工具对比
| 工具 | 适用场景 | 复杂度 |
|---|---|---|
| Compiler API(直接) | 需要最大控制权 | 高 |
| ts-morph | 代码分析、重构工具 | 中 |
| Language Service | 编辑器插件、LSP 服务器 | 高 |
| Transformer | 编译时代码变换 | 中 |
ESLint + @typescript-eslint |
常规 Lint 规则 | 低(推荐先考虑) |
Level 3 · 规范怎么定义的(资深)
TypeScript Compiler API 暴露了完整的编译器内部:AST 遍历(ts.forEachChild)、类型查询(checker.getTypeAtLocation)、符号解析(checker.getSymbolAtLocation)和代码转换(Transformer)。与 ESLint 的 AST 分析不同,Compiler API 可以查询任意节点的完整类型信息。ts-morph 是官方推荐的包装库,提供了链式 API 和安全重构操作。Language Service API 是所有 TypeScript IDE 功能(补全、跳转、重构)的底层驱动。
Level 4 · 边界与陷阱(所有人)
反模式
| 反模式 | 问题 |
|---|---|
不过滤 .d.ts 和 node_modules |
分析速度极慢,报告大量误报 |
直接比较 type.flags === TypeFlags.Any |
使用位运算 type.flags & TypeFlags.Any,flags 是位掩码 |
每次需要时都 createProgram |
应复用 program,重新创建代价高昂 |
在 Transformer 里修改字符串而不用 ts.factory |
直接字符串操作会破坏 source map |
忽略 checker.getAliasedSymbol |
通过 export { X } 导出的符号需要解引用才能比较 |
总结
| API | 用途 | 入口 |
|---|---|---|
ts.createProgram |
创建分析会话 | 所有工具的起点 |
program.getTypeChecker() |
获取类型检查器 | 类型查询的核心 |
ts.forEachChild |
AST 遍历 | 访问每个语法节点 |
checker.getTypeAtLocation |
获取节点类型 | 询问"这是什么类型" |
checker.getSymbolAtLocation |
获取符号 | 用于跨文件引用追踪 |
ts.createLanguageService |
编辑器服务 | 补全、诊断、重构 |
TransformerFactory |
代码变换 | 类 Babel 插件的 TS 版 |
ts-morph Project |
友好包装 | 快速工具开发首选 |