简介:TypeScript 的类型只存在于编译期,运行时会被整体擦除,因此无法像普通值那样被打印或传递。本文从一道经典面试题出发,梳理 TS 编译流水线的五个阶段,厘清类型空间与值空间的边界,并给出一套可运行的 Compiler API 实战方案:把任意类型序列化为字符串,让类型在调试、文档与测试等工程场景中真正可见、可用。
在一轮技术面试中,面试官在白板上写下了一段代码:
type A = "111";console.log(A);
并追问:“初学者经常把类型当成变量或值来使用,于是写出了这样的代码。你能通过某些特殊操作,让这段代码成功执行吗?”
很多人第一反应是定义一个同名常量塞进去,或者干脆把 “111” 硬编码进输出语句——这些都不算真正解题。要给出有说服力的答案,必须先回答一个更根本的问题:类型到底是什么?它与值的边界在哪里?本文就沿着这个问题,把 TS 的编译流程讲清楚,并给出一套可落地的方案:借助编译器开放的编程接口,把任意类型“打印”出来。
TS 里的声明分属两个互不相干的空间:
两个空间彼此独立,因此同名声明可以共存。class 就是典型的“双栖”声明——它同时向值空间贡献构造函数、向类型空间贡献实例类型;普通 enum 同样横跨两个空间。而 type A = "111" 中的 A 只存在于类型空间,这就是 console.log(A) 直接报错的根因:编译器会明确提示“A 仅仅是一个类型,却在此处被当作值使用”。
TS 的编译产物是纯 JS,所有类型注解、类型别名、接口定义都会被整体移除,这个过程一般被称为“类型擦除”。这带来三个推论:
所以严谨地说,“把类型变成值”在语言层面不成立。但这道面试题并没有关闭所有出口——类型虽然不进“用户的运行时”,却真实存在于“编译器的运行时”:编译器为了完成检查,必须在内存中构建出描述类型的对象。只要切换到编译器视角,类型就变成了可以被读取、遍历、序列化的数据。
想切换到编译器视角,先要理解编译器如何工作。TS 的编译过程通常被划分为五个阶段,每个阶段负责一种数据形态的转换:
值得一提的是,这五个阶段并不是一条严格单向、一次跑完的流水线:Checker 在解析复杂类型引用时会反向依赖 Binder 的符号信息,输出阶段的某些产物也依赖检查结论。但对解题而言,只需要记住一个关键事实:Checker 运行之后,源码中每个名字的类型都以 Type 对象的形式活在编译器进程的内存里。而这套能力被官方以 API 形式开放,即 Compiler API——主流编辑器与各类语言工具,本质上都是站在这些 API 之上构建的。
用 Compiler API 打印类型,最短路径只有三步:
ts.createProgram() 创建编译程序(以多文件为单位,因为类型解析必须依赖跨文件的完整上下文);program.getTypeChecker() 拿到类型检查器;checker.getTypeAtLocation() 取出类型,再用 checker.typeToString() 序列化为字符串。先准备一个类型稍微“花哨”一点的 demo.ts,用来验证复杂类型的打印效果:
// demo.tstype User = {id: number;name: string;tags: string[];};type Admin = User & { permissions: string[] };type Role = "admin" | "editor" | "viewer";type Unwrap<T> = T extends Promise<infer U> ? U : T;
再编写打印脚本(确保运行环境已安装 TS 本体):
// print-type.tsimport * as ts from "typescript";const filePath = "./demo.ts";// 1. 创建编译程序:入口文件 + 编译选项const program = ts.createProgram([filePath], {strict: true,target: ts.ScriptTarget.ES2020,});// 2. 获取类型检查器const checker = program.getTypeChecker();const sourceFile = program.getSourceFile(filePath);if (!sourceFile) throw new Error("未找到源文件,请检查路径");// 3. 深度遍历 AST,命中"类型别名声明"时打印其类型function visit(node: ts.Node): void {if (ts.isTypeAliasDeclaration(node)) {const type = checker.getTypeAtLocation(node.name);const text = checker.typeToString(type,node,ts.TypeFormatFlags.NoTruncation // 关闭长类型截断,避免被折叠成 "...");console.log(`type ${node.name.text} = ${text}`);}ts.forEachChild(node, visit);}visit(sourceFile);
把脚本接入构建流程或以命令行方式执行,可以得到形如下面的输出(具体格式随版本与上下文可能略有差异):
type User = { id: number; name: string; tags: string[]; }type Admin = User & { permissions: string[]; }type Role = "admin" | "editor" | "viewer"type Unwrap<T> = T extends Promise<infer U> ? U : T
至此,“打印类型”已经实现:虽然类型仍然不是运行时的值,但我们拿到了它的字符串形态——在观察与调试的意义上,它可以被当作一个“值”来看待了。
另外,官方还提供了 ts.createSourceFile(只解析、不检查的轻量入口)与 ts.createPrinter(把 AST 反向打印为源码)。但如果需要类型信息,就必须走 createProgram 这条完整链路——因为类型只有在 Checker 阶段之后才会诞生。
如果题目允许调整声明方式,最优雅的做法是掉转箭头:与其费力把类型变成值,不如让类型从值派生:
const A = "111"; // 值空间:真实的运行时常量type A = typeof A; // 类型空间:typeof 在类型位置是"类型查询",引用的是值空间的 Aconsole.log(A); // 正常输出 "111"let x: A = "111"; // 类型 A 照常参与检查
它能工作的原因有三:const 与 type 分属两个空间,同名并不冲突;类型位置的 typeof 是类型查询运算符,会去值空间寻找目标;const 常量在无注解时的类型恰好是字面量类型 “111”。这也是一条通用实践:当同一份信息需要同时服务运行时与类型系统时,优先声明值,再用 typeof 派生类型,二者天然同步、永不脱节。
如果题目要求 type A 是唯一事实来源、声明不可改,那就换一个执行时机——在构建期运行上一节的脚本,直接打印 A 的类型字符串。console.log 确实执行了,只是宿主从用户运行时换成了编译器进程。这个答案展示的,是对“类型活在哪个阶段”的准确理解:类型检查完成于编译期,那么类型的“打印”自然也应该发生在编译期。
更进一步,还可以基于 Compiler API 的转换器机制编写自定义转换:在产出 JS 之前遍历 AST,识别出“对类型空间标识符的引用”,用类型字符串字面量做原地替换。处理完成后,console.log(A) 一行不改也能在运行时打印出 “111”。但要清醒地认识到:这本质是编译期完成的宏替换,类型并没有真的变成运行时值,我们注入的只是它的一份字符串快照。除非有明确的工程动机,不建议在真实项目中滥用这种技巧。
把“类型 → 字符串”沉淀为工具之后,价值远不止面试:
“如何把 TS 类型变成值?”最严谨的回答是:做不到——类型与值分属两个空间,运行时注定被擦除。但在编译器的世界里,类型是真实存在的数据结构。掌握了 Scanner、Parser、Binder、Checker、Emitter 的流水线,再借道 Compiler API,我们就能把任何类型读取出来、序列化成字符串,让它像值一样被观察和消费。
面试题的三个答案也随之清晰:能改声明,就用 const + typeof 让类型从值派生;不能改声明,就把打印动作搬进编译期;追求极致,就在编译转换阶段把类型物化为字符串字面量。理解“类型是编译期数据”这个事实,正是类型体操从“炫技”走向“工程”的分水岭——下次再有人问“类型体操有什么用”,你至少可以回答:它让你在编译期拥有了一套可编程的类型分析能力。