本节目标:把前两节的「读」与「改」收口成「写」。读完你能看懂 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、IfStatement | kind、pos、end、parent |
NodeArray<T> | 带位置信息的数组 | statements、parameters | pos、end、hasTrailingComma |
Token | 叶子标记,无子节点 | {、;、=>、关键字 | kind、pos、end |
几个容易踩的点:
NodeArray不是普通数组。 它继承了Array,但额外带pos/end,所以能给整段参数列表定位。你几乎总是当数组用,但别对它做Array.prototype之外的花活。- 标点也是节点。
{这类 token 存在于node.getChildren()里,但不会出现在forEachChild的回调中。getChildren()返回含 token 与 trivia 的完整子列表,forEachChild只返回语义子节点。遍历逻辑一律用后者,需要精确位置时用前者。 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:
createXxx对应SyntaxKind.Xxx。 想要PropertySignature就找createPropertySignature。- 可选字段传
undefined。modifiers、typeParameters、typeArguments这类可选参数不写就传undefined,不要传空数组——空数组会打印出多余的分隔符。 - 标点用
createToken造。factory.createToken(ts.SyntaxKind.StringKeyword)得到string,factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken)得到=>。类型关键字与运算符都走这条路。
常用「类型表达式」的构造方式:
| 目标类型 | 构造方式 |
|---|---|
string | factory.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 默认转义非 ASCII | neverAsciiEscape: 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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。