【译】超越抽象语法树(AST),使用 TypeScript 的类型检查器

原文 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 运行给定的示例。

目标

看下面两个类型。你可以看到 stringnumber 两个基本类型、来自标准库的 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。

TS AST

看样子很不错,我想这可以适用于非常简单的类型 ?

AST 的问题是:它是静态分析,这意味着不会执行代码。这会缺少运行时信息。Typescript 需要运行代码来理解它并增加额外的语义。当你尝试使用 AST 解决问题时,将遇到以下问题:

  • AST 不会解析导入的文件,因为不会处理导入语句
  • 通过 keyof 或 typeof 创建的类型,依赖运行时构造
  • 类型守卫条件类型依赖于 typescript 的处理,否则你将无法理解和处理它们

AST 不能解决我们的问题。

走出 AST

我们需要另一种解决方案 ? 你使用的 IDE 整天都在进行这种类型的处理,例如,当你看到给定类型的审查和补全列表时。请看下面的屏幕截图,我将鼠标悬停在 IntelliJ 中的 NestedObjectType 类型上。IntelliJ 知道该类型的详细信息,这正是我们想要实现的。

NestedObjectType in IntelliJ

这是我们认为 IDE 理所当然该有的功能。IDE 是如何做到这一点的?它们是否为它们要支持的每种语言开发了一些神奇的分析?语言维护者必须提供一些工具来供 IDE 使用,在我们的例子中,来自 TypeScript。

语言服务和检查器

我花了几个小时研究这个话题,发现了一些对解决我的问题很重要的东西。

IDE 支持 Typescript,因为 TypeScript 提供了 tsserver,它是一个封装 TypeScript 编译器和语言服务的可执行文件。

你是否曾经在 IntelliJ 或 VSCode 中重启 Typescript 服务器,来调试类型或 typescript 的 tsconfig 问题?该服务器基于 tsserver,提供代码优化和补全支持功能。

tsserver 顾名思义是一个服务器,它不适合处理单个文件。如果你仔细阅读 typescript 架构概览,你会注意到图中有一个 checker.ts,它是 typescript 的核心。

typescript architecture overview

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

这就是前面所说的“标准库”问题。Datestring 类型导致了这种结果,我们需要在遍历到这些类型之前停止处理。

排除标准库中的类型

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.Symbolts.Type 或 ts.Node。然后从如此令人耳目一新和令人兴奋的角度,与自己编写的代码进行交互会变得越来越有趣 ✨

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYeLUEtE' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片