《TypeScript高级编程》5.3 AST 与代码生成

本节聚焦语法树的读取与生成。先看清 Node、NodeArray 与 Token 的结构差别,再用 ts.createPrinter 把改写后的树打回源码文本;接着从零构造 import、接口与类型别名,写出一个小型代码生成器,把 JSON 结构变成 .ts 文件,并解决注释丢失、格式被重排、产物幂等这三类工程问题。

本节目标:把前两节的「读」与「改」收口成「写」。读完你能看懂 TypeScript AST 里 Node、NodeArray、Token 三者的分工,用 ts.createPrinter 把任意子树还原成源码文本,并用 factory.createXxx 从零拼出一个可运行的 .ts 文件——也就是自己写一个 codegen。

5.3 AST 与代码生成

前两节我们建立了 Program、遍历了语法树、也在 transform 管线里改写节点。但所有改动最后都要落成磁盘上的文本,这一步靠的是 printer;而当需求变成「凭空造出一份 .ts 文件」时,靠的是 factory。

这两件事合起来就是 codegen。工程里到处都是它的影子:GraphQL 从 schema 生成类型、ORM 从表结构生成模型、i18n 从 JSON 生成键类型、组件库从图标目录生成 barrel 文件。它们的内核都是本节这几百行。

5.3.1 AST 的形状:Node、NodeArray、Token

先建立三个概念,后面所有操作都建立在它们之上:

概念是什么典型成员关键字段
Node语法结构的节点FunctionDeclaration、IfStatementkind、pos、end、parent
NodeArray<T>带位置信息的数组statements、parameterspos、end、hasTrailingComma
Token叶子标记,无子节点{、;、=>、关键字kind、pos、end

几个容易踩的点:

  1. NodeArray 不是普通数组。 它继承了 Array,但额外带 pos/end,所以能给整段参数列表定位。你几乎总是当数组用,但别对它做 Array.prototype 之外的花活。
  2. 标点也是节点。 { 这类 token 存在于 node.getChildren() 里,但不会出现在 forEachChild 的回调中。getChildren() 返回含 token 与 trivia 的完整子列表,forEachChild 只返回语义子节点。遍历逻辑一律用后者,需要精确位置时用前者。
  3. pos 是含前导 trivia 的位置。 node.pos 可能落在注释上,node.getStart(sf) 才是真正的起始偏移——这与上一节讲的文本提取是同一个坑。

5.3.2 从树到文本:createPrinter

printer 有两个常用方法,区别只在「打一个节点」还是「打一整个文件」:

import ts from "typescript";

const printer = ts.createPrinter();

// 打印单个节点:需要给出「宿主文件」,printer 靠它解析缩进与换行
const snippet = printer.printNode(ts.EmitHint.Unspecified, someNode, sourceFile);

// 打印整个 SourceFile
const full = printer.printFile(sourceFile);

printNode 的第二个参数是 EmitHint,它告诉 printer「你正在打印的是什么位置的东西」——同样的节点在不同位置需要的括号和换行不一样:

EmitHint含义例子
Unspecified不特别处理调试时打印任意节点
Expression作为表达式打印 a + b 会保留必要括号
IdentifierName作为标识符名属性访问的右侧
SourceFile作为完整文件等价于 printFile
EmbeddedStatement作为内嵌语句if 体里单语句不带花括号

第三个参数 sourceFile 不能随便传 undefined。printer 要用它来决定换行风格与缩进层级;对于从零构造、没有真实位置的节点,传 undefined 通常没事,但一旦节点混了真实位置信息,就可能抛 Cannot read properties of undefined。

5.3.3 printer 的选项

printer 的排版是重新生成的,不是保留原格式——AST 根本不存缩进和空行,只存位置区间。想控制输出风格,只能在 printer 上设选项:

const printer = ts.createPrinter({
  newLine: ts.NewLineKind.LineFeed,   // LF 还是 CRLF
  removeComments: false,              // 是否丢弃注释
  neverAsciiEscape: true,             // 中文不转成 \uXXXX
  omitTrailingSemicolon: false,       // 是否省掉末尾分号
});

其中 neverAsciiEscape 在生成含中文的产物时很关键:默认行为会把非 ASCII 字符转义成 Unicode 转义码,生成的类型名或注释会变成一串没人看得懂的十六进制序列。

5.3.4 从零构造:factory 的用法规律

context.factory(或直接 ts.factory)里全是 createXxx。掌握三条规律就能自己查 API:

  1. createXxx 对应 SyntaxKind.Xxx。 想要 PropertySignature 就找 createPropertySignature。
  2. 可选字段传 undefined。 modifiers、typeParameters、typeArguments 这类可选参数不写就传 undefined,不要传空数组——空数组会打印出多余的分隔符。
  3. 标点用 createToken 造。 factory.createToken(ts.SyntaxKind.StringKeyword) 得到 string,factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken) 得到 =>。类型关键字与运算符都走这条路。

常用「类型表达式」的构造方式:

目标类型构造方式
stringfactory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword)
string[]factory.createArrayTypeNode(stringNode)
"a" | "b"两个 createLiteralTypeNode 包在 createUnionTypeNode 里
{ id: number }factory.createTypeLiteralNode([...createPropertySignature])
Record<string, T>factory.createTypeReferenceNode("Record", [str, t])

5.3.5 案例:从 JSON 推断并生成 TS 类型

把这三条规律用起来,写一个「给一段 JSON 生成 interface」的小工具。第一步是递归推断类型节点:

function inferType(factory: ts.NodeFactory, value: unknown): ts.TypeNode {
  if (value === null) {
    return factory.createLiteralTypeNode(factory.createNull());
  }
  switch (typeof value) {
    case "string":
      return factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword);
    case "number":
      return factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
    case "boolean":
      return factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);
    case "object": {
      if (Array.isArray(value)) {
        const elem = value.length
          ? inferType(factory, value[0])
          : factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword);
        return factory.createArrayTypeNode(elem);
      }
      return factory.createTypeLiteralNode(
        Object.entries(value as Record<string, unknown>).map(([key, v]) =>
          factory.createPropertySignature(
            undefined,                        // modifiers
            factory.createIdentifier(key),    // name
            undefined,                        // questionToken
            inferType(factory, v)             // type
          )
        )
      );
    }
    default:
      return factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword);
  }
}

第二步把类型节点包成 type 别名并打印:

function generateTypeAlias(name: string, json: unknown): string {
  const factory = ts.factory;
  const alias = factory.createTypeAliasDeclaration(
    [factory.createToken(ts.SyntaxKind.ExportKeyword)],
    factory.createIdentifier(name),
    undefined,                 // typeParameters
    inferType(factory, json)
  );

  const file = factory.createSourceFile(
    [alias],
    factory.createToken(ts.SyntaxKind.EndOfFileToken),
    ts.NodeFlags.None
  );

  return ts.createPrinter().printFile(file);
}

输入 { id: 1, name: "a", tags: ["x"] },输出就是:

export type User = {
    id: number;
    name: string;
    tags: string[];
};

注意缩进是 4 空格、末尾有分号——这是 printer 的默认风格,不是你写的。想要别的风格,交给 Prettier 后处理,别试图用 printer 选项抠出来。

5.3.6 案例:生成 barrel 索引文件

第二个案例更贴近日常:给一个目录下的模块生成 index.ts。

function buildBarrel(modules: string[]): string {
  const factory = ts.factory;
  const decls = [...modules]
    .sort()                    // 排序保证产物稳定,避免 diff 噪音
    .map((m) =>
      factory.createExportDeclaration(
        undefined,                              // modifiers
        false,                                  // isTypeOnly
        undefined,                              // exportClause,undefined 表示 * as
        factory.createStringLiteral(`./${m}`),  // moduleSpecifier
        undefined                               // attributes
      )
    );

  const file = factory.createSourceFile(
    decls,
    factory.createToken(ts.SyntaxKind.EndOfFileToken),
    ts.NodeFlags.None
  );

  return ts.createPrinter().printFile(file);
}

sorted() 这一步不是洁癖:代码生成器必须幂等。同一份输入两次运行产出不同文件,会让 Git diff 永远有噪音,也会让 CI 的「生成物是否最新」检查失效。凡是遍历目录或对象的场景,输出前一律排序。

5.3.7 注释:AST 里没有它们

这是 codegen 最反直觉的一点:注释不属于 AST。scanner 把注释当 trivia 丢掉,createTypeAliasDeclaration 也没有「注释」参数。要加注释只能用「合成注释」:

ts.addSyntheticLeadingComment(
  alias,
  ts.SyntaxKind.MultiLineCommentTrivia,
  "* 本文件由 scripts/gen-types.ts 自动生成,请勿手动修改 ",
  true   // hasTrailingNewLine
);

addSyntheticLeadingComment 把注释挂在节点的 emitNode 上,printer 看到就会输出。对应的还有 addSyntheticTrailingComment 与 setSyntheticLeadingComments(一次挂多条)。true 那个参数表示注释后是否补一个换行,忘了它会得到 */export type ... 这种连在一行的产物。

文件级注释(版权头、eslint-disable)也走同一个机制,只是挂在 SourceFile 上:

ts.addSyntheticLeadingComment(
  file,
  ts.SyntaxKind.MultiLineCommentTrivia,
  "* eslint-disable */",
  true
);

如果需要在生成后保留原节点上的注释(比如改写既有文件而非从零生成),那就不能靠合成注释了——factory.updateXxx 会复用原节点的 emitNode,注释通常能跟着走;但用 createXxx 重建的节点会丢掉注释,这时候要手动把原节点的 synthetic comments 搬过去。

5.3.8 落盘与校验

生成完文本之后,落盘只要一行:

import { writeFileSync } from "node:fs";
writeFileSync("src/types/user.ts", text, "utf8");

但在写之前,强烈建议先做一次语法自检——把生成的文本再喂回 createSourceFile,看解析诊断是否为空:

const check = ts.createSourceFile(
  "gen.ts",
  text,
  ts.ScriptTarget.ESNext,
  false
);
if (check.parseDiagnostics.length > 0) {
  throw new Error(`生成结果语法非法:${check.parseDiagnostics[0].messageText}`);
}

这一步能把「字段传错位置」这类问题挡在写盘之前。更进一步的做法是把生成目录纳入一次 program 检查,让类型错误也在 CI 里暴露。生成 .d.ts 声明产物的完整流程见 .d.ts 生成与 exports 映射 。

生产里还有一个常见要求:「生成物必须与源码同步」。做法是在 CI 里跑一遍生成器但不提交结果,然后 git diff --exit-code:

npx tsx scripts/gen-types.ts
git diff --exit-code src/types/ || echo "生成物已过期,请重新运行 gen-types"

只要生成器是幂等的(排序 + 固定换行),这条检查就能稳定地拦住「改了 schema 忘了重新生成」的提交。

5.3.9 常见坑

现象原因处理
注释全丢注释是 trivia,不是节点addSyntheticLeadingComment
中文变成 中printer 默认转义非 ASCIIneverAsciiEscape: true
生成的类型显示为 string(标识符)用 createIdentifier("string") 而非关键字节点createKeywordTypeNode
可选字段前多出 ,给可选参数传了空数组传 undefined
printNode 抛 undefined 错误第三个参数传了 undefined 但节点带真实位置传入所属 SourceFile
每次生成 diff 都变遍历顺序不稳定输出前排序 + 固定换行 + 末尾换行

「可选字段前多出逗号」这类问题很难靠肉眼发现,因为产物看起来只是「格式有点怪」。把生成结果再 parse 一遍(5.3.8)能把大部分结构错误变成一条明确的报错。

5.3.10 代码生成在工具链里的位置

把本节和前两节连起来看:**读取用 forEachChild + TypeChecker,改写用 transformer + factory.updateXxx,生成用 factory.createXxx + printer。**三者共享同一个 ts.factory,这是 TypeScript 工具链统一性的体现。

延伸阅读:编译器代码生成 从编译器后端视角讲了 IR 到目标代码的流程,与本节的「AST 到源码」是同一类问题;AST 求值解释器 则展示了另一种用法——不打印文本,而是直接遍历求值;工程案例可以看 低代码平台的 codegen 与 DSL 与 GraphQL codegen 类型安全流程 。

小结

  • AST 里 Node 管结构、NodeArray 带位置、Token 是叶子;遍历用 forEachChild,要 token 用 getChildren()。
  • printer 负责树到文本:printFile 打整个文件,printNode 打单个节点,EmitHint 决定括号与换行。
  • 从零构造节点的三条规律:createXxx 对应 SyntaxKind、可选参数传 undefined、标点用 createToken。
  • 注释不属于 AST,必须用 addSyntheticLeadingComment 合成,否则一定丢失。
  • codegen 的两个工程底线:输出前排序保证幂等,写盘前重新 parse 保证语法合法。

至此第五章结束。我们已经能用 Compiler API 读、改、生成代码——这是「工具链开发者」视角的起点。下一章换个战场:不再自己驱动编译,而是把 TypeScript 放进编辑器,看 TS Server 与 LSP 如何支撑补全、跳转与重构。

阅读导航:上一节:5.2 自定义 transformer · 下一节:6.1 TS Server 与 LSP 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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