在TypeScript项目里写过一段时间代码后,一个很典型的困惑会浮现出来:我明明定义了一个接口,里面写了一堆字段,为什么运行的时候没办法拿到这些字段的名字?比如想遍历接口的所有属性做表单校验,或者想把联合类型的每个成员打印出来,编译器都毫无怨言,运行时却是一片空白。这个问题的根源在于TypeScript的类型系统是编译期结构,理解这一点是掌握这门语言的关键一步。本文就来拆解类型与运行时值的边界,并给出几种在运行时获取声明信息的实操方案。

类型擦除:为什么类型不能当值用
首先要明确一个事实:TypeScript编译的本质是类型擦除。编译器会把类型注解、接口定义、类型别名这些内容全部剥离,只留下纯粹的JavaScript代码。你在.ts文件里写的interface User { name: string },编译后不会留下任何痕迹,产出的JS里根本不存在User这个东西。
看一个直观的对比:
// 类型声明,编译后完全消失 type Status = 'active' | 'inactive' | 'pending'; // 下面这行会直接报编译错误 // console.log(Status); // 错误:Status只是一个类型,不能当值用 // typeof 检查的永远是运行时值,而不是类型 const s: Status = 'active'; console.log(typeof s); // 输出 "string",而不是 Status
这解释了一个常见误区:很多人以为typeof、instanceof这些运算符能感知类型信息,实际上它们操作的都是运行时值。typeof返回的是JavaScript的原始类型字符串,对接口、类型别名的存在一无所知。所以任何"在运行时读取类型定义"的想法,在默认情况下都是行不通的。
不过有几个例外值得注意:enum是TypeScript中唯一会生成运行时对象的类型结构,class也会产出真实的构造函数,还有const enum会在编译时直接内联常量值。除此之外的type、interface、联合类型、映射类型统统是纯粹的编译期概念。想跨越这道边界,必须引入一个真实存在于运行时的结构。
方案一:用 const 对象加 as const 反向推导联合类型
既然类型不能生成值,那就反过来——先写值,再让类型从值推导出来。这是社区里最主流的做法,核心思路是定义一个const对象,用as const断言锁定字面量类型,然后借助typeof和keyof拿到所有键组成的联合类型。
// 运行时真实存在的对象
const DIRECTIONS = {
up: 'UP',
down: 'DOWN',
left: 'LEFT',
right: 'RIGHT',
} as const;
// 从值反推类型
type DirectionKey = keyof typeof DIRECTIONS; // 'up' | 'down' | 'left' | 'right'
type DirectionValue = typeof DIRECTIONS[keyof typeof DIRECTIONS]; // 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'
// 现在运行时和类型都有了
Object.keys(DIRECTIONS).forEach((key) => {
console.log(key, DIRECTIONS[key as DirectionKey]);
});
这个方案的优势是单一数据源:对象字面量既是运行时数据,又是类型的推导依据,改一处两边同步,不会出现类型和值不一致的尴尬。缺点是语法略显绕,typeof X[keyof typeof X]这种写法对新手不太友好,建议封装成注释清晰的基础模块。
如果需要的只是一组简单常量,还可以用数组实现同样效果:
const FRUITS = ['apple', 'banana', 'orange'] as const;
type Fruit = typeof FRUITS[number]; // 'apple' | 'banana' | 'orange'
// 运行时可以正常遍历
FRUITS.forEach((f) => console.log(f));
// 类型层面也能做穷尽检查
function getPrice(f: Fruit): number {
switch (f) {
case 'apple': return 5;
case 'banana': return 3;
case 'orange': return 4;
}
}
方案二:枚举——唯一自带运行时对象的类型结构
enum是TypeScript给这道边界开的官方口子。枚举定义会编译成一个真实的JavaScript对象,因此在运行时可以访问它的成员,类型层面也能引用它:
enum LogLevel {
Debug = 'DEBUG',
Info = 'INFO',
Warn = 'WARN',
Error = 'ERROR',
}
// 运行时:枚举是真实对象
console.log(LogLevel.Debug); // "DEBUG"
console.log(Object.values(LogLevel)); // ["DEBUG", "INFO", "WARN", "ERROR"]
// 类型层面:可以用成员做类型标注
function log(level: LogLevel, message: string) { /* ... */ }
// 双向映射(仅数字枚举)
enum Num { A, B }
console.log(Num[0]); // "A"
枚举的好处是开箱即用,不用写额外的推导代码。但它也有争议:字符串枚举没有反向映射,数字枚举的反向映射有时会造成意外行为;此外枚举是TypeScript对JavaScript的语法扩展,无法与某些构建工具或标准演进方向(如using声明、装饰器标准)完美贴合。所以不少团队规范里会倾向用as const对象替代枚举,这也是上面方案一流行起来的原因。
还有一种折中:const enum会在编译时把成员内联成字面量,不生成运行时对象,性能更好,但代价是失去了运行时访问能力,适合纯类型场景。要注意的是,在isolatedModules模式下,跨文件使用const enum会被禁止,实际项目中要先确认构建链路是否支持。
类型与值的判断技巧:什么时候该选哪种方案
选择方案前,先问自己一个问题:这块信息在运行时是否需要被读取?如果只是给编译器做检查用,比如约束函数参数、定义返回值结构,那就老老实实用type和interface,不要为了"看起来方便"引入多余的运行时对象。如果需要在运行时遍历、序列化、做穷尽校验,那就必须有真实值结构。
两者的判断可以用一个简单对照来总结:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 纯类型约束,运行时不涉及 | type / interface | 零运行时开销,类型擦除后不占体积 |
| 需要遍历一组常量 | as const 对象或数组 | 单一数据源,类型自动同步 |
| 需要语义化的命名空间 | enum | 官方支持,运行时可直接访问成员 |
| 对包体积敏感的常量 | const enum | 编译期内联,无运行时对象 |
另外一个进阶技巧是类与装饰器的组合。TypeScript 5.0之后标准的装饰器语法配合emitDecoratorMetadata,可以在运行时拿到类的构造参数类型等元数据,这也是NestJS等框架实现依赖注入的基础。不过这属于框架层面的元编程,普通业务代码中用前面几个方案已经足够。
最后要提醒的是,即使引入了运行时结构,类型安全依然不能替代运行时校验。来自接口请求、用户输入的数据,编译器无法保证其形状,这类场景应该配合zod、valibot这类库,用一份schema同时生成类型和运行时校验逻辑,真正做到类型与值的边界双向打通。
TypeScript类型字面量类型类型与运行时边界修改时间:2026-09-10 22:24:54