TypeScript 7.0 正在把一批旧的编译选项从“警告”变成“不再工作”,其中影响面最大的就是emitDecoratorMetadata。这个选项曾经是 NestJS、TypeORM、class-validator 等领域模型代码能拿到参数类型和属性类型的关键。Rfclt 提供了一条新的运行时类型元数据路径,它不依赖emitDecoratorMetadata,也不需要你在每个类上手工维护一套类型定义文件。在讨论 Rfclt 怎么落地之前,先要搞清楚旧的元数据机制为什么被放弃,以及运行时类型元数据在 TS 7.0 里到底缺了什么。
如果你的项目还在使用reflect-metadata,或者代码里频繁出现Reflect.getMetadata("design:paramtypes", ...),这篇文章会直接关系到你的升级决策。下面从 TS 7.0 的弃用背景开始,讲清楚问题链路,再用一个最小生成器示例说明 Rfclt 的落地方式,最后给出迁移时的报错排查顺序和检查清单。
1. 为什么 TS 7.0 之前emitDecoratorMetadata能工作,之后不能
1.1emitDecoratorMetadata解决了什么问题
在旧版 TypeScript 装饰器规范下,如果tsconfig.json同时开启了experimentalDecorators和emitDecoratorMetadata,编译器遇到带装饰器的类、方法、属性或参数时,会额外生成一组Reflect.metadata调用。这些调用会把三样东西注册进元数据:
design:type:被装饰成员的类型。design:paramtypes:构造函数或方法的参数类型列表。design:returntype:方法返回值类型。
典型代码如下:
@Controller("/user") class UserController { constructor(private readonly userService: UserService) {} }在旧版本中,编译器编译后大致会生成类似下面的逻辑:
__decorate([ Controller("/user"), __metadata("design:paramtypes", [UserService]) ], UserController);框架拿到design:paramtypes之后,就知道UserController的构造函数需要注入UserService。这正是很多依赖注入容器能自动推断构造函数参数的原因。
问题是:这个行为从来没有进入 ECMAScript 标准。它不是 JavaScript 语言的能力,而是 TypeScript 编译器自定义的一段“发射逻辑”。标准装饰器提案中,装饰器只暴露被装饰元素的结构,并不会自动给出被装饰成员的类型信息。类型信息在编译后仍然会消失。
1.2 TS 7.0 清理旧选项对运行时的真实影响
TS 7.0 是编译器架构切换后的一个大版本。除了迁移到新的代码生成后端,它还会清理一批历史遗留选项。你可以把emitDecoratorMetadata的弃用提示想象成和其他旧选项一样:
Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0.emitDecoratorMetadata虽然没有和baseUrl在同一时间点被宣布,但方向和逻辑是一致的:TypeScript 不想继续为不是标准的元数据发射机制维护一套分支复杂的旧逻辑,尤其是新装饰器规范已经来到标准轨道之后。
真实影响在于运行时。关闭emitDecoratorMetadata后,编译器不再生成design:*元数据。于是所有依赖这套元数据的库,都会在运行时拿不到类型信息。常见表现有:
- 依赖注入框架无法推断构造函数参数。
- ORM 无法从实体属性反推列类型。
- 校验库无法自动检查属性类型。
- 序列化工具不知道字段是可选的还是必填的。
这不是“某个库小版本出 bug”,而是底层元数据源被切断。只要代码还在运行时依赖design:*,就必须找到新的元数据来源。
1.3 Rfclt 在元数据链路中的位置
Rfclt 做的事情,是把“从 TypeScript 类型信息生成运行时元数据”这一步从编译器的装饰器发射中拆出来,放到构建期显式执行。
这里的关键区别是:
- 旧机制:写类 -> 编译时隐式生成元数据 -> 运行时在装饰器中反射获取。
- Rfclt 思路:写类 -> 构建期扫描类型 -> 生成独立元数据文件 -> 运行时直接读取该文件。
Rfclt 不需要你在每个类上额外维护一份 schema,因为它读取的是 TypeScript 编译器已经有的类型信息。它也不需要打开experimentalDecorators或emitDecoratorMetadata,因为它不依赖旧装饰器执行顺序,而是通过 TypeScript Compiler API 在构建阶段把类型结构提取出来。
如果把运行时类型信息比作一份地图,旧方案是在路上插牌子,Rfclt 则是在出发前先画好地图,运行时不猜类型,直接查表。
2. 运行时类型元数据到底缺了什么,为什么不能靠反射补上
2.1 类型信息在编译器输出中的消失过程
先看一段简单的 TypeScript 代码:
class User { id: number; name: string; email?: string; constructor(id: number, name: string) { this.id = id; this.name = name; } }编译成 JavaScript 之后,你已经看不到任何number、string的痕迹:
class User { constructor(id, name) { this.id = id; this.name = name; } }JavaScript 运行时只有值,没有静态类型。如果 Rfclt 不提前把类型信息保存下来,运行时无论如何反射,都只能看到“这个属性叫id”,永远看不到“id是number类型”。
这也是很多人误以为“运行时反射可以替代类型元数据”的原因。反射能看到结构和值,但看不到编译期才存在的类型标注。
2.2 旧框架对 design:* 元数据的依赖
很多框架在运行时并不会直接读取 TypeScript 类型,它们读取的是Reflect.getMetadata里的内容。以几个常见场景为例:
| 框架或库 | 依赖内容 | 升级 TS 7.0 后的常见表现 |
|---|---|---|
| NestJS | design:paramtypes推断依赖注入 | 构造函数参数无法自动注入 |
| TypeORM | design:type推断实体列类型 | 实体字段类型缺失 |
| class-validator | design:type做类型校验 | 属性校验不生效 |
| class-transformer | design:type做反序列化类型转换 | 嵌套对象无法转换 |
| routing-controllers | design:paramtypes做参数绑定 | 请求参数无法自动映射 |
这些库不是都不能用了,而是必须改成显式传参方式,例如 NestJS 的@Inject(),或者 TypeORM 的@Column()显式类型。但这么做会让代码冗余,并且失去“类型信息自动传递”的便利。Rfclt 想补的,正是这种被 TS 7.0 抽走的自动传递能力。
2.3 运行时类型元数据需要覆盖的三个维度
一个相对完整的运行时类型元数据方案,至少要覆盖三个维度:
| 维度 | 说明 | 运行时用途 |
|---|---|---|
| 属性类型 | 每个字段是什么类型、是否可选 | 校验、序列化、表单生成 |
| 构造参数类型 | 构造函数参数列表 | 依赖注入、参数解析 |
| 方法的入参与出参类型 | 方法参数和返回值 | API 契约、搜索文档、RPC 参数校验 |
在最小实现里,可以优先覆盖属性类型和构造参数类型。返回值类型往往和业务校验关系不大,但如果是做 RPC 或 OpenAPI 文档生成,最好也一并生成。
Rfclt 的价值不是发明一套新类型系统,而是把 TypeScript 已有的类型模型,在编译期拷贝一份到运行时可读的数据结构中。
3. 从构建期拿到类型信息:Rfclt 的生成式实现
3.1 整体流程
Rfclt 的一个可落地实现路径是生成一个.meta.ts文件,文件内容包含每个类的属性结构。整体流程如下:
- 配置一个构建脚本,输入需要扫描的源码文件列表。
- 创建 TypeScript Program,走编译器类型检查通道。
- 遍历 SourceFile 中的类声明和属性声明。
- 从 TypeChecker 获取每个属性的静态类型。
- 把结果写成独立模块,例如
src/generated/rfclt.meta.ts。 - 运行时直接 import 这个模块,不再依赖
Reflect.getMetadata。
这个流程的好处是,生成结果可以进入版本控制。团队成员不需要执行额外脚本,就能通过import使用同一份元数据。
3.2 遍历 AST 生成类属性元数据
下面用一个最小脚本演示核心逻辑。运行环境需要安装 TypeScript,以及一个能直接执行 TS 脚本的工具,例如tsx或ts-node。
import * as ts from "typescript"; import * as fs from "node:fs"; import * as path from "node:path"; interface PropertyMeta { type: string; optional: boolean; } interface ClassMeta { properties: Record<string, PropertyMeta>; } function collectClassMetadata(fileNames: string[], options: ts.CompilerOptions) { const program = ts.createProgram(fileNames, options); const checker = program.getTypeChecker(); const classes: Record<string, ClassMeta> = {}; for (const sourceFile of program.getSourceFiles()) { if (sourceFile.isDeclarationFile) { continue; } ts.forEachChild(sourceFile, (node) => { if (!ts.isClassDeclaration(node) || !node.name) { return; } const className = node.name.text; const properties: Record<string, PropertyMeta> = {}; for (const member of node.members) { if (!ts.isPropertyDeclaration(member) || !member.name) { continue; } const propertyName = member.name.getText(sourceFile); const propertyType = checker.getTypeAtLocation(member); const typeText = checker.typeToString(propertyType, node, ts.TypeFormatFlags.NoTruncation); properties[propertyName] = { type: typeText, optional: member.questionToken !== undefined }; } classes[className] = { properties }; }); } return classes; } const projectRoot = path.resolve(__dirname, ".."); const fileNames = [path.join(projectRoot, "src/entities/user.ts")]; const options: ts.CompilerOptions = { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.NodeNext, strict: true }; const metadata = collectClassMetadata(fileNames, options); const output = `export const metadata = ${JSON.stringify({ classes: metadata }, null, 2)} as const;\n`; fs.writeFileSync(path.join(projectRoot, "src/generated/rfclt.meta.ts"), output);这段代码的核心是checker.getTypeAtLocation(member)。它拿到的是编译器解析后的真实类型,而不是源代码字符串。对于id: number,输出就是number;对于email?: string,输出会记录optional: true。
这里要注意,示例为了控制篇幅只处理了属性声明。实际项目中通常还要处理继承关系、泛型参数、构造函数的parameterProperties,比如constructor(private id: number)这种写法就不会出现在member.name的普通属性遍历中。
3.3 生成独立的.meta.ts文件
假设源码是这样的:
// src/entities/user.ts export class User { id: number; name: string; email?: string; }脚本生成的结果大致如下:
export const metadata = { classes: { User: { properties: { id: { type: "number", optional: false }, name: { type: "string", optional: false }, email: { type: "string", optional: true } } } } } as const;生成文件可以放在src/generated目录中,并提交到 git。运行时和业务代码都不需要再触发 TypeScript 编译器的类型检查,直接import这个模块就能拿到结构化信息。
3.4 在 tsconfig 中关闭旧的 decorator 开关
Rfclt 生成器不需要emitDecoratorMetadata,也不需要experimentalDecorators。如果项目还没有迁移到新装饰器规范,建议按下面方向调整tsconfig.json:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "strict": true, "experimentalDecorators": false, "emitDecoratorMetadata": false } }如果你的业务代码还在使用旧装饰器语法,且框架又要求experimentalDecorators,那么迁移要分两步:先把依赖design:*的框架逻辑改造为显式传参或 Rfclt 元数据校验,再把装饰器语法切到标准装饰器。不要在同一个版本里同时切换编译器底层和框架依赖,否则问题会混在一起,难以定位。
4. 在运行时消费这些元数据
4.1 用类装饰器接入校验
有了生成好的元数据文件,运行时消费方式就很简单。下面用一个类装饰器实现“必填字段和基础类型校验”。
import { metadata } from "./generated/rfclt.meta.js"; function getClassMeta(ctor: Function) { const className = ctor.name; return metadata.classes[className as keyof typeof metadata.classes]; } export function ValidateRfclt() { return function <T extends new (...args: any[]) => any>( Ctor: T, _context: ClassDecoratorContext ) { return class extends Ctor { constructor(...args: any[]) { super(...args); const classMeta = getClassMeta(Ctor); if (!classMeta) { return; } for (const [key, schema] of Object.entries(classMeta.properties)) { const value = (this as any)[key]; if (!schema.optional && value === undefined) { throw new TypeError(`Property ${key} is required`); } if (value !== undefined && typeof value !== schema.type) { throw new TypeError(`Property ${key} should be ${schema.type}`); } } } }; }; }使用方式:
import { ValidateRfclt } from "./decorators/validate-rfclt"; @ValidateRfclt() class UserDTO { id!: number; name!: string; email?: string; } const user = new UserDTO(); user.id = 1; user.name = "Alice";如果id或name没有赋值,装饰器会在构造函数中抛出异常。这种实现虽然简单,但已经能演示 Rfclt 的核心价值:类型信息在运行时是只读数据,而不是隐藏在编译器内部逻辑里。
注意:示例只做基础
typeof校验,不支持嵌套对象和数组类型。生产环境中需要递归解析schema.type,或者把类型信息拆成更细的节点,例如{ kind: "array", itemType: "string" }。
4.2 调试验证:用测试断言元数据正确性
在 Rfclt 落地过程中,最容易踩的坑是“元数据生成了,但是不对”。建议给生成的.meta.ts写一组单元测试:
import { describe, expect, it } from "vitest"; import { metadata } from "../generated/rfclt.meta.js"; import { User } from "../entities/user.js"; describe("Rfclt runtime metadata", () => { it("should describe User 的属性类型", () => { const props = metadata.classes.User.properties; expect(props.id.type).toBe("number"); expect(props.id.optional).toBe(false); expect(props.email.optional).toBe(true); }); it("should keep class name stable", () => { expect(metadata.classes.User).toBeDefined(); expect(User.name).toBe("User"); }); });如果将来有人改了实体类型但忘记重新生成元数据,测试就会失败。这也是推荐把生成文件提交到版本控制的原因:测试能第一时间发现元数据和源码不一致。
4.3 运行环境差异:Node、浏览器、打包器
Rfclt 生成的.meta.ts最终会被编译成普通 JavaScript 模块。它不包含design:*,也不依赖reflect-metadata,因此在 Node.js、浏览器和打包器环境中都能正常运行。
需要注意的差异如下:
| 环境 | 需要处理的点 |
|---|---|
| Node.js ESM | 生成文件使用export const,直接 import 即可 |
| 浏览器原生 ESM | 避免在生成模块中引用 Node.js API |
| Webpack / Vite / esbuild | 生成模块没有装饰器副作用,压缩器不会误删 |
| 老版本 Node | 需要保证target和运行时兼容,例如转译到 ES2019 |
生产环境还有一个容易被忽略的点:压缩器可能重命名类名,但不会重命名字符串 key。如果元数据文件里的类名和运行时类名不一致,查找就会失败。解决方式是生成时不要只保存类名字符串,必要时保存构造函数引用,或者用Symbol作为 key。
5. 不用 Rfclt 的替代方案,以及它们差在哪里
5.1 手工 schema 与代码生成对比
很多团队在 TS 7.0 来临后选择手写 zod schema:
import { z } from "zod"; export const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string().optional() }); export type User = z.infer<typeof UserSchema>;这种方式没有历史包袱,但会引入额外的心智负担。实体类、数据库模型、接口 DTO 都需要单独维护一份 schema,类型一变,schema 容易漏改。Rfclt 走的是生成式路线,类型信息来源仍然是 TypeScript 类型检查器,理论上更容易和现有实体类保持一致。
5.2 标准装饰器元数据 Symbol.metadata 的补充位置
新的 ECMAScript 装饰器提案中,提供了Symbol.metadata作为装饰器元数据的标准入口。它的定位是 ECMAScript 标准层面的元数据容器,但它不会自动填充 TypeScript 类型信息。也就是说,Symbol.metadata能告诉你“这个类被哪些装饰器访问过”,但不能告诉你“这个属性是 number 还是 string”。
Rfclt 可以把生成的类型信息挂到Symbol.metadata上,从而兼容标准装饰器体系。具体做法是在生成代码中保留类型描述对象,在运行时把该对象注册到类的Symbol.metadata上。这样既不会依赖旧的design:*,也能让框架通过标准入口读取。
5.3 方案选型速查表
| 方案 | 是否生成代码 | 对 TS 7.0 兼容性 | 运行时开销 | 维护成本 | 适用场景 |
|---|---|---|---|---|---|
| 手写 zod / io-ts | 不生成 | 兼容 | 中 | 较高 | 接口边界明确、全新项目 |
继续依赖emitDecoratorMetadata | 不生成 | 不兼容 | 中 | 不适用于 7.0 | 不推荐继续使用 |
| transformer 类工具 | 编译期生成 | 需要构建器支持 | 小 | 中 | 对构建链路有控制权 |
| Rfclt 生成式元数据 | 构建期生成.meta.ts | 兼容 | 小 | 低 | 存量实体想要自动迁移 |
选型时不要只看“能不能校验类型”。要问三个问题:运行时读元数据的方式是否标准;生成结果是否可版本控制;类型变更时是否会产生提示。Rfclt 的生成式思路在这三方面相对均衡。
6. 从旧版本迁移到 TS 7.0 的排查路径和最佳实践
6.1 典型报错与定位方法
迁移到 TS 7.0 后,旧项目通常会先出现运行时错误,而不是编译错误。因为关闭emitDecoratorMetadata后,类型仍能通过编译,但运行时元数据缺失。下面按现象排查:
| 现象 | 直接原因 | 排查顺序 |
|---|---|---|
| 启动时报缺少参数元数据 | 框架通过design:paramtypes做依赖分析 | 先搜索design:paramtypes和Reflect.getMetadata |
| 控制器注入参数变成 undefined | 依赖注入容器无法推断构造参数类型 | 检查框架是否支持@Inject等显式 Provider |
| 校验库不再校验类型 | class-validator 依赖design:type | 切换到基于 Rfclt 的 schema 校验 |
| 序列化后字段丢失类型转换 | class-transformer 依赖design:type | 改用生成元数据或显式类型声明 |
| 元数据文件 import 失败 | moduleResolution或 import 后缀问题 | 确认生成文件路径和NodeNext模块解析规则 |
排查顺序首先看输入是否正确,也就是生成脚本是否真的覆盖了对应文件;再看tsconfig是否关闭了emitDecoratorMetadata;然后看运行时 import 的是不是最新生成的元数据;最后才怀疑框架兼容性。不要一上来就改框架配置。
6.2 三个最容易被忽略的坑
第一个坑:把类名当作唯一元数据 key。
压缩器、改名、代码拆分都可能让Ctor.name变化。生成文件保存的是字符串类名,运行时却通过Ctor.name查找,一旦不一致就拿到空元数据。
推荐做法是在生成时输出一个辅助函数,直接保存类引用:
import { User } from "../entities/user.js"; export const classMetaMap = new Map<Function, ClassMeta>([ [User, { properties: { /* ... */ } }] ]);第二个坑:继承属性没被收集。
如果Admin extends User,生成器只遍历了Admin自己的members,那么Admin实例上的id、name在运行时没有元数据。解决方案是生成时递归收集父类属性,或者运行时沿着原型链层层查找。
第三个坑:optional和undefined混淆。
email?: string和email: string | undefined在类型语义上不同。前者表示可以缺失,后者表示值可以是undefined。校验逻辑必须区分处理,不能只用typeof value === "undefined"判断。建议在元数据里额外保存includesUndefined标志。
6.3 发布前检查清单
迁移不是把emitDecoratorMetadata改成false就算完成。下面是一份可复用的检查清单:
| 检查项 | 验证方式 |
|---|---|
所有emitDecoratorMetadata引用已清理 | 执行grep -r "emitDecoratorMetadata" --include="*.json" --include="*.ts" . |
代码中不再依赖Reflect.getMetadata("design: | 搜索design:type、design:paramtypes |
| Rfclt 生成脚本进入构建流程 | npm run build后检查src/generated/rfclt.meta.ts是否有更新 |
| 生成文件已提交版本控制 | git status 中能观察到元数据变更 |
| 单元测试覆盖关键实体元数据 | 运行测试套件,断言属性类型和 optional 标记 |
| 生产打包产物包含元数据 | 在压缩产物中搜索类名字符串或Symbol.metadata |
| 依赖注入框架改用显式 Provider | 逐个验证入口控制器启动正常 |
| 校验逻辑覆盖嵌套类型 | 对type: "Array"或对象类型做递归校验测试 |
发布前最应该重视的是“生成脚本是否可复现”。如果元数据只在本地生成,CI 重新拉代码后不执行生成步骤,就会出现本地正常、线上缺失的情况。推荐把元数据生成命令纳入npm run build或 CI pipeline 的第一步。
TypeScript 7.0 对旧装饰器元数据的清理,本质上是在提醒开发团队:运行时需要什么信息,应该显式声明或构建期生成,而不是依赖编译器的隐式魔法。Rfclt 这类方案的价值,不在于生成多少行元数据代码,而在于它把运行时类型信息变成一份可检查、可测试、可版本控制的产物。
如果你正在维护一个老项目,先不要一次性迁移所有实体。建议先挑一个没有复杂继承、也没有泛型的 DTO 类接入 Rfclt,跑通“源码 -> 生成元数据 -> 运行时校验 -> 单测断言”这条链路。再逐步扩展到控制器参数、ORM 实体和 API 文档生成。泛型和类继承这类复杂场景,放到第二阶段,等生成器稳定后再处理。