如何把 TS 类型变成“值”?从编译原理到实战

作者:KAKAKA2026.11.07 02:40浏览量:0

简介:TypeScript 的类型只存在于编译期,运行时会被整体擦除,因此无法像普通值那样被打印或传递。本文从一道经典面试题出发,梳理 TS 编译流水线的五个阶段,厘清类型空间与值空间的边界,并给出一套可运行的 Compiler API 实战方案:把任意类型序列化为字符串,让类型在调试、文档与测试等工程场景中真正可见、可用。

在一轮技术面试中,面试官在白板上写下了一段代码:

  1. type A = "111";
  2. console.log(A);

并追问:“初学者经常把类型当成变量或值来使用,于是写出了这样的代码。你能通过某些特殊操作,让这段代码成功执行吗?”

很多人第一反应是定义一个同名常量塞进去,或者干脆把 “111” 硬编码进输出语句——这些都不算真正解题。要给出有说服力的答案,必须先回答一个更根本的问题:类型到底是什么?它与值的边界在哪里?本文就沿着这个问题,把 TS 的编译流程讲清楚,并给出一套可落地的方案:借助编译器开放的编程接口,把任意类型“打印”出来。

一、类型的本质:只存在于编译期的静态描述

1.1 类型空间与值空间

TS 里的声明分属两个互不相干的空间:

  • 值空间:const、let、function 声明,class 的构造部分,enum 的运行时对象等。这些声明会生成真实的 JS 代码,运行时可以访问。
  • 类型空间:type、interface、泛型参数、索引访问类型、typeof 类型查询等。它们只参与编译期检查,编译产物中不留任何痕迹。

两个空间彼此独立,因此同名声明可以共存。class 就是典型的“双栖”声明——它同时向值空间贡献构造函数、向类型空间贡献实例类型;普通 enum 同样横跨两个空间。而 type A = "111" 中的 A 只存在于类型空间,这就是 console.log(A) 直接报错的根因:编译器会明确提示“A 仅仅是一个类型,却在此处被当作值使用”。

1.2 类型擦除:运行时注定拿不到类型

TS 的编译产物是纯 JS,所有类型注解、类型别名、接口定义都会被整体移除,这个过程一般被称为“类型擦除”。这带来三个推论:

  1. 无论你把类型写得多复杂,在浏览器或服务端的 JS 运行时里它都“不存在”;
  2. 运行时没有任何反射机制能取回 type A;
  3. 类型的唯一职责,是让编译器在编译期判断“这段代码是否类型正确”。

所以严谨地说,“把类型变成值”在语言层面不成立。但这道面试题并没有关闭所有出口——类型虽然不进“用户的运行时”,却真实存在于“编译器的运行时”:编译器为了完成检查,必须在内存中构建出描述类型的对象。只要切换到编译器视角,类型就变成了可以被读取、遍历、序列化的数据。

二、编译器视角:TS 的五阶段流水线

想切换到编译器视角,先要理解编译器如何工作。TS 的编译过程通常被划分为五个阶段,每个阶段负责一种数据形态的转换:

  1. Scanner(扫描器):接收源码文本,逐字符扫描并输出 Token 序列。Token 记录了词法类别(关键字、标识符、字符串字面量、运算符、注释等)以及所处的行列位置。
  2. Parser(解析器):把 Token 流组装成抽象语法树(AST)。AST 的根节点代表整个源文件,每个子节点都有对应的语法类别标记,忠实还原代码结构。
  3. Binder(绑定器):遍历 AST,为每个声明生成 Symbol(符号),建立“标识符名 → 具体声明”的映射。它回答的是作用域问题:某个名字到底指向哪个声明。
  4. Checker(类型检查器):基于 Symbol 与 AST 进行类型推导与检查,为每个表达式确定唯一的 Type 对象,同时产出全部类型诊断。编辑器里的悬停提示、自动补全与错误波浪线,底层都依赖这一步。
  5. Transform/Emitter(转换与输出):把 AST 转换为目标 JS 并写出文件,按需生成 .d.ts 声明产物。类型信息在此阶段被擦除——这正是上一节“类型擦除”发生的现场。

值得一提的是,这五个阶段并不是一条严格单向、一次跑完的流水线:Checker 在解析复杂类型引用时会反向依赖 Binder 的符号信息,输出阶段的某些产物也依赖检查结论。但对解题而言,只需要记住一个关键事实:Checker 运行之后,源码中每个名字的类型都以 Type 对象的形式活在编译器进程的内存里。而这套能力被官方以 API 形式开放,即 Compiler API——主流编辑器与各类语言工具,本质上都是站在这些 API 之上构建的。

三、实战:把任意类型序列化为字符串

3.1 核心链路

用 Compiler API 打印类型,最短路径只有三步:

  1. ts.createProgram() 创建编译程序(以多文件为单位,因为类型解析必须依赖跨文件的完整上下文);
  2. program.getTypeChecker() 拿到类型检查器;
  3. 在目标节点上 checker.getTypeAtLocation() 取出类型,再用 checker.typeToString() 序列化为字符串。

3.2 完整示例

先准备一个类型稍微“花哨”一点的 demo.ts,用来验证复杂类型的打印效果:

  1. // demo.ts
  2. type User = {
  3. id: number;
  4. name: string;
  5. tags: string[];
  6. };
  7. type Admin = User & { permissions: string[] };
  8. type Role = "admin" | "editor" | "viewer";
  9. type Unwrap<T> = T extends Promise<infer U> ? U : T;

再编写打印脚本(确保运行环境已安装 TS 本体):

  1. // print-type.ts
  2. import * as ts from "typescript";
  3. const filePath = "./demo.ts";
  4. // 1. 创建编译程序:入口文件 + 编译选项
  5. const program = ts.createProgram([filePath], {
  6. strict: true,
  7. target: ts.ScriptTarget.ES2020,
  8. });
  9. // 2. 获取类型检查器
  10. const checker = program.getTypeChecker();
  11. const sourceFile = program.getSourceFile(filePath);
  12. if (!sourceFile) throw new Error("未找到源文件,请检查路径");
  13. // 3. 深度遍历 AST,命中"类型别名声明"时打印其类型
  14. function visit(node: ts.Node): void {
  15. if (ts.isTypeAliasDeclaration(node)) {
  16. const type = checker.getTypeAtLocation(node.name);
  17. const text = checker.typeToString(
  18. type,
  19. node,
  20. ts.TypeFormatFlags.NoTruncation // 关闭长类型截断,避免被折叠成 "..."
  21. );
  22. console.log(`type ${node.name.text} = ${text}`);
  23. }
  24. ts.forEachChild(node, visit);
  25. }
  26. visit(sourceFile);

把脚本接入构建流程或以命令行方式执行,可以得到形如下面的输出(具体格式随版本与上下文可能略有差异):

  1. type User = { id: number; name: string; tags: string[]; }
  2. type Admin = User & { permissions: string[]; }
  3. type Role = "admin" | "editor" | "viewer"
  4. type Unwrap<T> = T extends Promise<infer U> ? U : T

至此,“打印类型”已经实现:虽然类型仍然不是运行时的值,但我们拿到了它的字符串形态——在观察与调试的意义上,它可以被当作一个“值”来看待了。

3.3 关键 API 速览

  • ts.createProgram(rootNames, options):以文件集合为输入建立完整编译视图,是一切类型求值的前提;
  • checker.getTypeAtLocation(node):给定 AST 节点,返回该位置对应的 Type 对象,是从“代码位置”进入“类型世界”的入口;
  • checker.typeToString(type, enclosingNode, flags):把 Type 对象格式化为可读文本;NoTruncation 标志保证长类型不被省略号截断,调试类型体操时几乎是必开项;
  • ts.forEachChild(node, callback):通用的 AST 深度遍历辅助函数,配合 ts.isTypeAliasDeclaration、ts.isInterfaceDeclaration 等类型守卫,可以精准筛选目标节点。

另外,官方还提供了 ts.createSourceFile(只解析、不检查的轻量入口)与 ts.createPrinter(把 AST 反向打印为源码)。但如果需要类型信息,就必须走 createProgram 这条完整链路——因为类型只有在 Checker 阶段之后才会诞生。

四、回到面试题:三种解法

4.1 思路一:反转方向,让类型由值派生

如果题目允许调整声明方式,最优雅的做法是掉转箭头:与其费力把类型变成值,不如让类型从值派生:

  1. const A = "111"; // 值空间:真实的运行时常量
  2. type A = typeof A; // 类型空间:typeof 在类型位置是"类型查询",引用的是值空间的 A
  3. console.log(A); // 正常输出 "111"
  4. let x: A = "111"; // 类型 A 照常参与检查

它能工作的原因有三:const 与 type 分属两个空间,同名并不冲突;类型位置的 typeof 是类型查询运算符,会去值空间寻找目标;const 常量在无注解时的类型恰好是字面量类型 “111”。这也是一条通用实践:当同一份信息需要同时服务运行时与类型系统时,优先声明值,再用 typeof 派生类型,二者天然同步、永不脱节。

4.2 思路二:把 console.log 搬进编译器

如果题目要求 type A 是唯一事实来源、声明不可改,那就换一个执行时机——在构建期运行上一节的脚本,直接打印 A 的类型字符串。console.log 确实执行了,只是宿主从用户运行时换成了编译器进程。这个答案展示的,是对“类型活在哪个阶段”的准确理解:类型检查完成于编译期,那么类型的“打印”自然也应该发生在编译期。

4.3 思路三(进阶):编译转换,把类型物化为字面量

更进一步,还可以基于 Compiler API 的转换器机制编写自定义转换:在产出 JS 之前遍历 AST,识别出“对类型空间标识符的引用”,用类型字符串字面量做原地替换。处理完成后,console.log(A) 一行不改也能在运行时打印出 “111”。但要清醒地认识到:这本质是编译期完成的宏替换,类型并没有真的变成运行时值,我们注入的只是它的一份字符串快照。除非有明确的工程动机,不建议在真实项目中滥用这种技巧。

五、这套能力还能做什么

把“类型 → 字符串”沉淀为工具之后,价值远不止面试:

  1. 调试类型体操:递归条件类型、infer 推导、映射类型层层展开后到底是什么形态,typeToString 一次看全,比肉眼脑补可靠得多;
  2. 自动生成接口文档:序列化导出函数的参数与返回值类型,可自动产出与代码强同步的结构化文档;
  3. 类型快照测试:把关键公共类型的字符串表示固化进测试用例,一旦有人意外改动类型定义,流水线立即失败——比“口头约定不许改”可靠得多;
  4. 看懂编辑器的行为:主流编辑器悬停显示的类型提示、重构时的推导,背后正是同一套检查器与类型字符串化机制。理解它,你就更容易写出“对编辑器友好”的类型定义。

六、总结

“如何把 TS 类型变成值?”最严谨的回答是:做不到——类型与值分属两个空间,运行时注定被擦除。但在编译器的世界里,类型是真实存在的数据结构。掌握了 Scanner、Parser、Binder、Checker、Emitter 的流水线,再借道 Compiler API,我们就能把任何类型读取出来、序列化成字符串,让它像值一样被观察和消费。

面试题的三个答案也随之清晰:能改声明,就用 const + typeof 让类型从值派生;不能改声明,就把打印动作搬进编译期;追求极致,就在编译转换阶段把类型物化为字符串字面量。理解“类型是编译期数据”这个事实,正是类型体操从“炫技”走向“工程”的分水岭——下次再有人问“类型体操有什么用”,你至少可以回答:它让你在编译期拥有了一套可编程的类型分析能力。