原文 Going beyond the Abstract Syntax Tree (AST) with the TypeScript Type Checker
我们目前为企业客户开发一个低代码平台,其中根据给定的配置文件排列和连接 Angular 组件。组件基于泛型定义,因此平台知道它们之间可以流动的数据。为了执行类型检查,我们使用一个已经包含所有相关类型信息的 json 文件。我们通过递归方式将 TypeScript 类型解析为基本类型来收集类型信息,然后将得到的属性名称与它们的类型名称存储在一起。这篇文章是关于我们如何收集类型信息,以及我们为什么无法仅通过 AST 来实现这一目标。
你可以在 github.com/georgiee/typescript-type-checker-beyond-ast 上找到代码库,并且可以直接在浏览器中使用 code sandbox 运行给定的示例。
目标
看下面两个类型。你可以看到 string
和 number
两个基本类型、来自标准库的 Date
类型和类型别名 NestedObjectType
,别名引用的对象类型可以包含基本类型和其他对象类型。
type MainObjectType = {
propertyWithTypeAlias: NestedObjectType;
};
type NestedObjectType = {
value1: string;
value2: number;
value3: Date;
};
我们希望上述内容的理想输出,是由冒号分隔的属性名和类型名列表,包含属性所在位置的层次结构。
MainObjectType:
propertyWithTypeAlias: NestedObjectType
value1: string
value2: number
value3: Date
我们的处理规则如下:
- 类型别名,例如
propertyWithTypeAlias: NestedObjectType
,解析为引用的类型。这可以是其他类型别名或基本类型。 - 基本类型本身不再被处理了,按原样输出,例如
value1: string
和value2: number
。 - 我们不需要获取标准库中类型的详细信息,例如
value3: Date
。但它们可能会引入几十个我们不想在输出列表中看到的属性。
让我们看看如何解决这个问题。
可以使用 AST 么?
为了解决这个问题,你很快就会想到 AST(抽象语法树)。AST 是一种数据结构,以机器可读的格式表示源代码的结构。通过 TypeScript AST 查看器,可以查看 AST。
看样子很不错,我想这可以适用于非常简单的类型 ?
AST 的问题是:它是静态分析,这意味着不会执行代码。这会缺少运行时信息。Typescript 需要运行代码来理解它并增加额外的语义。当你尝试使用 AST 解决问题时,将遇到以下问题:
AST 不能解决我们的问题。
走出 AST
我们需要另一种解决方案 ? 你使用的 IDE 整天都在进行这种类型的处理,例如,当你看到给定类型的审查和补全列表时。请看下面的屏幕截图,我将鼠标悬停在 IntelliJ 中的 NestedObjectType
类型上。IntelliJ 知道该类型的详细信息,这正是我们想要实现的。
这是我们认为 IDE 理所当然该有的功能。IDE 是如何做到这一点的?它们是否为它们要支持的每种语言开发了一些神奇的分析?语言维护者必须提供一些工具来供 IDE 使用,在我们的例子中,来自 TypeScript。
语言服务和检查器
我花了几个小时研究这个话题,发现了一些对解决我的问题很重要的东西。
IDE 支持 Typescript,因为 TypeScript 提供了 tsserver,它是一个封装 TypeScript 编译器和语言服务的可执行文件。
你是否曾经在 IntelliJ 或 VSCode 中重启 Typescript 服务器,来调试类型或 typescript 的 tsconfig 问题?该服务器基于 tsserver
,提供代码优化和补全支持功能。
tsserver
顾名思义是一个服务器,它不适合处理单个文件。如果你仔细阅读 typescript 架构概览,你会注意到图中有一个 checker.ts
,它是 typescript 的核心。
checker.ts 是 TypeScript 代码库中的一个巨大的文件。现在有 42,000 行代码,它的大小为 2.5 MB ? 这可能是要正确处理给定 TypeScript 文件,并充分利用其类型,在基于 AST 后仍需要编写的代码量。
类型检查器(checker.ts)
让我们深入研究类型检查器,看看它如何帮助我们应对给定的挑战。不幸的是,我找不到任何关于类型检查器的文档,这使得初次使用非常困难。我主要在 GitHub 上搜索一些代码示例,阅读 checker.ts
代码,并使用断点调试器来调试涉及的数据。
下面的代码展示了使用 TS 获取类型最关键的部分。创建一个应用,获取类型检查器,然后使用该类型检查器进行分析。
const program: ts.Program = ts.createProgram(files, tsConfig);
const checker: ts.TypeChecker = program.getTypeChecker();
// 使用检查器
const classSymbol = checker.getSymbolAtLocation(node.name);
// ...
使用类型检查器
创建一个类型检查器非常简单,但涉及到具体的使用细节通常就会变得复杂。让我们一步一步地进行处理。首先准备一个文件 file-with-type.ts
,它包含我们要分析的类型。
// file-with-types.ts
type MainObjectType = {
propertyWithTypeAlias: NestedObjectType;
};
type NestedObjectType = {
value1: string;
value2: number;
value3: Date;
};
我想通过这个文件解答以下问题:
使用
checker.ts
,我们如何访问类型NestedObjectType
的详细信息,以便得知MainObjectType
上的propertyWithTypeAlias
包含三个属性?
第一步,创建类型检查器并使用 program.getSourceFile
分析源文件,该文件返回一个 ts.SourceFile
实例。
import * as ts from "typescript";
const files: string[] = ['file-with-types.ts']
const program: ts.Program = ts.createProgram(files, {});
const checker: ts.TypeChecker = program.getTypeChecker();
const mySourceFile: ts.SourceFile = program.getSourceFile('file-with-types.ts');
使用我们准备的源文件,深入分析文件内容。首先,我们必须使用 AST 来访问文件中我们关注的类型节点。接着,传入类型检查器获取详细的类型信息。当调用 ts.forEachChild((node: ts.Node) => {/*...*/})
时,将创建一个循环来遍历 AST 上的所有节点(ts.Node
)。
? 你可以通过 ts-ast-viewer.com 更好地了解 AST 结构
我们希望从名为 MainObjectType
的类型开始我们的类型分析。我们可以通过在循环遍历所有节点时,查找名为 MainObjectType
的 AST 节点来实现这一点。
ts.forEachChild(mySourceFile, node => {
if (ts.isTypeAliasDeclaration(node) && node.name.escapedText === "MainObjectType") {
// [...处理该类型]
}
});
node
的类型是 ts.Node
,并不包含 node.name
属性。你可以使用 ts.isTypeAliasDeclaration(node)
方法来检查它是否为 TypeAliasDeclaration
类型。通过该类型守卫,typescript 能够正确访问 node.name
,而不会抛出类型错误。
通过找到的 AST 节点,我们可以使用 checker.getTypeAtLocation(node)
方法,从类型检查器获得更多信息。我们传入节点,从检查器中获得一个 ts.Type
实例。这是一个包含更多语义的对象,以此让我们超越 AST。
// 不要混淆,“name” 不是字符串类型,而是包含更多信息的对象
const mainObjectType = checker.getTypeAtLocation(node.name);
就是这里,我们到达了类型范畴 ?
分析属性
我们可以通过 mainObjectType.getProperties()
方法访问给定类型的所有属性,然后得到属性名以及类型名。
const [propertyWithTypeAlias] = mainObjectType.getProperties();
/**
* `propertyType` 将包含 `NestedObjectType` 类型的所有信息
*/
const propertyType = checker.getTypeOfSymbolAtLocation(propertyWithTypeAlias, node);
const propertyTypeName = checker.typeToString(propertyType);
请记住,我们目前正在处理第一层级:
type MainObjectType = {
propertyWithTypeAlias: NestedObjectType;
};
在这个层级上,我们只有一个属性:原类型定义中的 NestedObjectType
,所以我们可以省略一个循环,简单地提取第一个元素。该值为 ts.Symbol
类型,类似于 ts.Type
,与 AST 的 ts.Node
相比,增加了更多的语义。
// 打印为 `propertyWithTypeAlias: NestedObjectType`
console.log(`${propertyWithTypeAlias.name}: ${propertyTypeName}`)
继续下一层级,获取嵌套属性的类型。正如以下代码所示,我们使用 for 循环来获取所有属性。
// 我们现在处理的是存储在 `propertyType` 中的属性
for (const nestedProperty of propertyType.getProperties()) {
const nestedPropertyType = checker.getTypeOfSymbolAtLocation(nestedProperty, node);
const nestedPropertyTypeName = checker.typeToString(nestedPropertyType);
/** 打印如下
├── value1: string
├── value2: number
├── value3: Date
*/
console.log(` ├── ${nestedProperty.name}: ${nestedPropertyTypeName}`)
}
实际使用中的其他问题
上述例子是为了专注于类型提取过程专门编写的。在实际使用中,还有一些重要的问题需要解决:
- 我们不知道类型嵌套的深度,可以通过递归解决,也可以构造一个循环。
- 我们需要判断类型是否来自标准库,如
Date
和字符串等基本类型,因为我们通常对这些属性的细节不感兴趣。
递归
首先,我们通过递归分析,获取给定文件中的所有属性。
function processProperty(type: ts.Type, node: ts.Node, level = 0) {
if(level === 0) {
console.group(`.\n└──Processing '${checker.typeToString(type)}'`)
}
for (const property of type.getProperties()) {
const propertyType = checker.getTypeOfSymbolAtLocation(property, node);
const propertyTypeName = checker.typeToString(propertyType);
processProperty(propertyType, node, level + 1)
console.log(` ├── ${property.name}: ${propertyTypeName}`)
}
console.groupEnd();
}
ts.forEachChild(myComponentSourceFile, node => {
if (ts.isTypeAliasDeclaration(node) && node.name.escapedText === "MainObjectType") {
const mainObjectType = checker.getTypeAtLocation(node.name);
processProperty(mainObjectType, node);
}
});
这将获取所有属性,无论它嵌套的深度如何。这是因为 processProperty()
递归所有嵌套属性。
这段代码的运行结果包含大量的噪音。请看下面的日志,其中包含大量标准库类型的属性,标有 ?
是我们定义的属性。
.
└──Processing 'MainObjectType'
├── toString: () => string
├── charAt: (pos: number) => string
├── charCodeAt: (index: number) => number
├── concat: (...strings: string[]) => string
├── indexOf: (searchString: string, position?: number) => number
├── lastIndexOf: (searchString: string, position?: number) => number
├── localeCompare: { (that: string): number; (that: string, locales?: string | string[], options?: CollatorOptions): number; }
├── match: { (regexp: string | RegExp): RegExpMatchArray; (matcher: { [Symbol.match](string: string): RegExpMatchArray; }): RegExpMatchArray; }
├── replace: { (searchValue: string | RegExp, replaceValue: string): string; (searchValue: string | RegExp, replacer: (substring: string, ...args: any[]) => string): string; (searchValue: { ...; }, replaceValue: string): string; (searchValue: { ...; }, replacer: (substring: string, ...args: any[]) => string): string; }
├── search: { (regexp: string | RegExp): number; (searcher: { [Symbol.search](string: string): number; }): number; }
├── slice: (start?: number, end?: number) => string
├── split: { (separator: string | RegExp, limit?: number): string[]; (splitter: { [Symbol.split](string: string, limit?: number): string[]; }, limit?: number): string[]; }
├── substring: (start: number, end?: number) => string
├── toLowerCase: () => string
├── toLocaleLowerCase: (locales?: string | string[]) => string
├── toUpperCase: () => string
├── toLocaleUpperCase: (locales?: string | string[]) => string
├── trim: () => string
├── toString: (radix?: number) => string
├── toFixed: (fractionDigits?: number) => string
├── toExponential: (fractionDigits?: number) => string
├── toPrecision: (precision?: number) => string
├── valueOf: () => number
├── toLocaleString: (locales?: string | string[], options?: NumberFormatOptions) => string
├── length: number
├── substr: (from: number, length?: number) => string
├── valueOf: () => string
├── codePointAt: (pos: number) => number
├── includes: (searchString: string, position?: number) => boolean
├── endsWith: (searchString: string, endPosition?: number) => boolean
├── normalize: { (form: "NFC" | "NFD" | "NFKC" | "NFKD"): string; (form?: string): string; }
├── repeat: (count: number) => string
├── startsWith: (searchString: string, position?: number) => boolean
├── anchor: (name: string) => string
├── big: () => string
├── blink: () => string
├── bold: () => string
├── fixed: () => string
├── fontcolor: (color: string) => string
├── fontsize: { (size: number): string; (size: string): string; }
├── italics: () => string
├── link: (url: string) => string
├── small: () => string
├── strike: () => string
├── sub: () => string
├── sup: () => string
├── padStart: (maxLength: number, fillString?: string) => string
├── padEnd: (maxLength: number, fillString?: string) => string
├── trimLeft: () => string
├── trimRight: () => string
├── trimStart: () => string
├── trimEnd: () => string
├── __@iterator@596: () => IterableIterator<string>
?├── value1: string
├── toString: (radix?: number) => string
├── toFixed: (fractionDigits?: number) => string
├── toExponential: (fractionDigits?: number) => string
├── toPrecision: (precision?: number) => string
├── valueOf: () => number
├── toLocaleString: (locales?: string | string[], options?: NumberFormatOptions) => string
?├── value2: number
├── toString: () => string
├── toDateString: () => string
├── toTimeString: () => string
├── toLocaleString: { (): string; (locales?: string | string[], options?: DateTimeFormatOptions): string; }
├── toLocaleDateString: { (): string; (locales?: string | string[], options?: DateTimeFormatOptions): string; }
├── toLocaleTimeString: { (): string; (locales?: string | string[], options?: DateTimeFormatOptions): string; }
├── valueOf: () => number
├── getTime: () => number
├── getFullYear: () => number
├── getUTCFullYear: () => number
├── getMonth: () => number
├── getUTCMonth: () => number
├── getDate: () => number
├── getUTCDate: () => number
├── getDay: () => number
├── getUTCDay: () => number
├── getHours: () => number
├── getUTCHours: () => number
├── getMinutes: () => number
├── getUTCMinutes: () => number
├── getSeconds: () => number
├── getUTCSeconds: () => number
├── getMilliseconds: () => number
├── getUTCMilliseconds: () => number
├── getTimezoneOffset: () => number
├── setTime: (time: number) => number
├── setMilliseconds: (ms: number) => number
├── setUTCMilliseconds: (ms: number) => number
├── setSeconds: (sec: number, ms?: number) => number
├── setUTCSeconds: (sec: number, ms?: number) => number
├── setMinutes: (min: number, sec?: number, ms?: number) => number
├── setUTCMinutes: (min: number, sec?: number, ms?: number) => number
├── setHours: (hours: number, min?: number, sec?: number, ms?: number) => number
├── setUTCHours: (hours: number, min?: number, sec?: number, ms?: number) => number
├── setDate: (date: number) => number
├── setUTCDate: (date: number) => number
├── setMonth: (month: number, date?: number) => number
├── setUTCMonth: (month: number, date?: number) => number
├── setFullYear: (year: number, month?: number, date?: number) => number
├── setUTCFullYear: (year: number, month?: number, date?: number) => number
├── toUTCString: () => string
├── toISOString: () => string
├── toJSON: (key?: any) => string
├── getVarDate: () => VarDate
├── __@toPrimitive@755: { (hint: "default"): string; (hint: "string"): string; (hint: "number"): number; (hint: string): string | number; }
?├── value3: Date
?├── propertyWithTypeAlias: NestedObjectType
这就是前面所说的“标准库”问题。Date
和 string
类型导致了这种结果,我们需要在遍历到这些类型之前停止处理。
排除标准库中的类型
TypeScript 提供了很多工具来做到这一点。这是我为我们的用例构建的工具方法 isTypeLocal
。
function isTypeLocal(symbol: ts.Symbol) {
const sourceFile = symbol?.valueDeclaration?.getSourceFile();
const hasSource = !!sourceFile;
const isStandardLibrary = hasSource && program.isSourceFileDefaultLibrary(sourceFile!)
const isExternal = hasSource && program.isSourceFileFromExternalLibrary(sourceFile!);
const hasDeclaration = !!symbol?.declarations?.[0];
return !(isStandardLibrary || isExternal) && hasDeclaration;
}
该方法将检测给定符号是否属于标准库(Date)、外部库(任何来自于 node_modules
的类型)以及没有实际声明的所有内容,如基本类型(string、number)。
我们将使用该工具方法来避免继续递归那些不需要的类型:
if(isTypeLocal(propertySymbol)) {
// 它是我们定义的类型,打印它
// 并继续递归处理它的属性
console.group(` └── ${property.name}: ${propertyTypeName}`)
processProperty(propertyType, node, level + 1)
} else {
// 不是我们定义的类型,所以打印它但不继续做任何处理
console.log(` ├── ${property.name}: ${propertyTypeName}`)
}
更新后的代码更加灵活。让我们处理一个包含更深层次嵌套类型的 MainObjectType
并查看控制台。
更新 file-with-types.ts
文件:
type NestedObjectType = {
value1: string;
value2: number;
value3: Date;
value4: SomethingElse;
};
type SomethingElse = {
value2: PrettyNestedType;
};
type PrettyNestedType = {
value1: string;
value2: number;
value3: Date;
};
type MainObjectType = {
value1: string;
value2: number;
value3: Date;
propertyWithTypeAlias: NestedObjectType;
};
上述文件控制台打印如下。每个标准库类型都会被跳过,正确列出我们需要的名称和类型。
.
└──Processing 'MainObjectType'
├── value1: string
├── value2: number
├── value3: Date
└── propertyWithTypeAlias: NestedObjectType
├── value1: string
├── value2: number
├── value3: Date
└── value4: SomethingElse
└── value2: PrettyNestedType
├── value1: string
├── value2: number
├── value3: Date
任务完成 ✅
结论
与类型检查器交互与与 AST 交互同样困难。这是因为你并不完全清楚 TypeScript 给你的数据是什么,这使得这项任务非常困难。
直到今天,我仍然依靠 debugger
和 console.log
来寻找解决方案。一段时间后,当有了一定经验,你会更轻松地处理 ts.Symbol
、ts.Type
或 ts.Node
。然后从如此令人耳目一新和令人兴奋的角度,与自己编写的代码进行交互会变得越来越有趣 ✨