第 28 章

Compiler API:用 ts.createProgram 写自己的类型检查工具

第28章:Compiler API:用 ts.createProgram 写自己的类型检查工具

理解Compiler API是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用用 ts.createProgram 写自己的类型检查工具?关键的设计决策和陷阱是什么?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

Compiler API 暴露了什么

TypeScript 的编译器不是一个黑盒。typescript npm 包导出了完整的编译器 API,让你可以:

这比 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 所有语法节点的类型名称:FunctionDeclarationVariableStatementCallExpression 等。

访问者模式(更实用)

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.isFunctionDeclarationts.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.tsnode_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 友好包装 快速工具开发首选
本章评分
4.5  / 5  (3 评分)

💬 留言讨论