TypeScript 7 的编译器和编辑器工具被用 Go 重新实现了一遍。这次重写里有个不起眼但绕不开的问题:AST 节点模型到底该怎么建。

目标读者是写 Go 或 TypeScript 的人,不需要编译器内部经验。这本质上是个通用的建模问题——怎么表示几种相关但不同的数据形状,又不至于把所有可能用到的字段全塞进一个巨大的结构体里,再挂上一堆可选字段。

打开网易新闻 查看精彩图片

从一段最简单的代码说起

假设编译器收到这样一行代码:total(1, 2)。解析器会把它表示成一棵 AST,也就是一棵捕捉源码结构的节点树。

  • Call 节点,代表一次调用
  • Identifier 节点:total
  • Number 节点:1
  • Number 节点:2

这里的 Call、Identifier、Number 是节点种类,也就是对不同语法角色打的标签。后续阶段读的是这棵树,而不是重新去解释源码文本。

每种节点需要的信息并不一样。标识符需要文本,比如 total;数字需要它的值或原始文本;调用需要被调用的表达式和它的参数。

但有些信息对每种节点都有用。比如编译器经常需要知道节点在原始文件中的位置,这样才能把错误显示在正确的地方;它可能还需要一个指向父节点的链接。

于是真正的问题浮出来了:怎么用一个 Go 类型表示所有节点共有的东西,同时又把只有调用才有的字段和只有标识符才有的字段隔开?

最直接的做法:一个大结构体

在 Go 里,最朴素的第一版实现就是一个结构体。对 TypeScript 读者来说,这类似于定义一个带若干可选属性的对象形状。

这个结构体会包含 Kind 字段,说明这是调用、标识符还是数字;Start 和 End 记录节点在源码中的字节位置;Parent 指向包含它的节点;Text 给标识符或数字用,调用用不上;Callee 和 Arguments 给调用用,标识符和数字用不上。

这不算错。它容易创建、容易阅读,对小型格式来说往往很合适。

麻烦出现在模型长大之后。用这个类型,Go 会允许一些毫无意义的组合。比如一个标识符可以同时拥有 Text 和 Arguments,尽管只有调用才该有参数。结构体本身表达不了这条规则,程序里每个地方都得自己记住它。

一次检查无伤大雅。但编译器有很多节点种类,也有很多地方要读它们。重复同样的检查,就更容易让某个读取方忘掉某条规则。新增一种节点,往往还会带来更多字段,而这些字段对几乎所有已有节点来说都是空的。

TypeScript 用联合类型描述合法形状

TypeScript 针对这种情况有可辨识联合。一个共享的标签,通常是 kind,把运行时的值和该形状允许的字段关联起来。对 Go 读者来说,这是标签值和与之匹配的字段之间的一种编译期关系。

标识符节点类型里,kind 固定为 identifier,start 和 end 是所有节点共有的源码位置,text 只有标识符才有。调用节点类型里,kind 固定为 call,callee 是要调用的表达式,arguments 是传给它的值。Node 则是这两种合法形状之一,而不是两者的任意混合。

检查过 kind 之后,TypeScript 会收窄这个联合类型:它知道在这个分支里哪些字段可以安全使用。这个检查发生在 TypeScript 被编译时,也就是 JavaScript 运行之前。

关键的好处不在语法本身,而在于类型模型说出了哪些组合是合法的。

Go 需要另一种方式表达同样的分离

Go 没有内置的可辨识联合。TypeScript-go 的做法是把一个共享的结构体头部和一个私有接口组合起来。Go 接口可以持有一个提供其方法的具值,类型断言则用来检查它当前持有的是哪个具值。

TypeScript-go 项目正是用这些特性把 AST 拆成两部分:一个中心的 Node 持有所有节点共享的数据,一个私有的 nodeData 值持有其余数据。这样,只有调用才有的字段就不会出现在标识符身上,反之亦然。

这套模型解决的不是语法糖问题,而是让类型系统替人记住规则——哪些字段在哪种节点上才有意义,不再依赖每个读取方自觉。