
昨天在做业务建模时,看着 TypeScript 的 interface 定义,想到一个问题。
TypeScript 的类型系统在编译后会被擦除( Type Erasure )。这意味着 age: number 这样的约束只存在于开发阶段,运行时完全不可见。
但实际上,这些元数据完整地存在于源码中。如果能写个脚本,在编译时分析源码 AST ,把这些类型信息提取并保存下来,是不是就能在运行时直接复用了?
吃饱了撑的尝试实现了个原型。
其实最直观的例子,就写的代码里。
interface User { posts: Post[]; } 这处理是类型约束,其实也顺便描述了业务关系:User 下面有多个 Post 。
如果不去引用那些额外的装饰器、配置文件,直接复用类型定义来描述关系,是不是也行得通?
顺着这个思路,既然显式的“模型关系”可以从 Post[] 这样的类型结构中直接读出来,那更隐晦的“校验规则”(比如字符串长度、格式限制)是不是也能想办法“寄生”在类型里?
如果能同时把“关系”和“规则”都收敛在类型定义中,并通过编译分析提取给运行时使用,那 interface 就不仅仅是静态检查的工具,而变成了完整的业务逻辑描述。
既然决定要从类型里提取信息,那先试试最简单的“关系”。
比如 posts: Post[]。
在 TypeScript 编译器的视角中,这行代码对应着一个结构严谨的 AST (抽象语法树)节点。
编译器通过 PropertySignature 识别属性名,利用 ArrayType 确定数组结构,并借助 TypeReference 锁定元素类型 Post。这些细粒度的结构化数据(可通过 TypeScript AST Viewer 直观查看)完整保留了代码的语义信息。
核心逻辑在于利用 [Compiler API](( https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API)) (记录下,他是个强大的工具集,允许开发者像编译器一样“理解”代码。) 遍历 AST:一旦识别到数组类型的属性定义,便将其提取并映射为“一对多”的关系描述。经过转换,源码中的类型定义就被标准化为一份配置 JSON:
"relations": { "posts": { "type": "hasMany", "target": "Post" } } 这样,模型关系配置就可以直接复用类型定义。
关系搞定了,接下来是更复杂的校验规则(如 minLen、email)。TypeScript 本身没有地方直接写 minLen 这种东西,所以好像需要一个载体。
在 TypeScript 的泛型可以是实现一种 Phantom Type (幽灵类型):
// T 是实际运行时的类型 // Config 是仅编译期存在的元数据 type Field<T, Config> = T; Field<string, ...> 在运行时就是普通的 string。泛型参数 Config 虽然会被编译擦除,但在 AST 中是可以读取到的。
这样好像就可以在不影响运行时逻辑的前提下嵌入元数据。
看起来像是:
// src/domain/models.ts // 引入我定义的“幽灵类型” import type { Str, Num } from '@bizmod/core'; import type { MinLen, Email, BlockList } from '@bizmod/rules'; export interface User { id: Str; // 多个规则一起用:最少 2 个字 + 违禁词过滤 name: Str<[ MinLen<2>, BlockList<["admin", "root"]> ]>; email: Str<[Email]>; } 在编辑器里,name 依然是字符串,该怎么用怎么用,完全不影响开发。但在代码文本里,那个 MinLen 和 BlockList 的标记就留在那儿了。
定义好类型载体,下一步就是把这些规则信息也读出来。我查了一下,这里正好可以用 TypeScript 的 Compiler API 来实现。
简单来说,它能把 .ts 文件变成一棵可以遍历的树( AST )。我们写个脚本,遍历所有的 interface。当发现属性使用了 Field 类型时,读取其泛型参数(比如 MinLen、admin),并保存下来。
核心逻辑大概是这样(简化版):
// analyzer.ts (伪代码) function visit(node: ts.Node) { // 1. 找到所有 Interface if (ts.isInterfaceDeclaration(node)) { const modelName = node.name.text; // 拿到 "User" // 2. 遍历它的属性 node.members.forEach(member => { const fieldName = member.name.text; // 拿到 "name" // 3. 重点:解析泛型参数! // 这里能拿到 "MinLen", "BlockList" 甚至里面的 ["admin", "root"] const rules = extractRulesFromGeneric(member.type); schema[modelName][fieldName] = rules; }); } } 运行脚本后,生成了一个完整的 schema.json,包含了关系和校验规则:
{ "User": { "name": "User", "fields": { "name": { "type": "string", "required": true, "rules": { "minLen": 2, "blockList": ["admin", "root"] } }, "email": { "type": "string", "rules": { "email": true } } }, "relations": { "posts": { "type": "hasMany", "target": "Post" } } } } 代码里的信息就被提取出来了存成了清单。
前面的脚本跑完以后,所有这些信息(校验规则 + 模型关系)就都存进了 schema.json 里。
--
有了这个文件,运行时要做的事情就很简单了。
--
程序启动时读取这个 JSON 。当 API 接收到数据时,根据 JSON 里的规则自动执行校验逻辑。
这样就实现了把 TypeScript 的静态类型信息带到运行时使用。
以后新增业务模型,只需要维护一份 interface 定义,校验规则和关系定义都会自动同步生成。
--
为了验证可行性,写个测试。
1. 类型定义
利用 Phantom Type 携带元数据:
// types.ts // T 是真实类型,Rules 是元数据 export type Field<T, Rules extends any[]> = T; // 定义一个规则类型 export type MinLen<N extends number> = { _tag: 'MinLen', val: N }; // 业务代码 export interface User { name: Field<string, [MinLen<2>]>; } 2. 编译器分析 (Analyzer)
使用 TS Compiler API 提取元数据(简化版):
// analyzer.ts import * as ts from "typescript"; function analyze(fileName: string) { const program = ts.createProgram([fileName], {}); const sourceFile = program.getSourceFile(fileName)!; ts.forEachChild(sourceFile, node => { // 1. 找到 Interface if (!ts.isInterfaceDeclaration(node)) return; node.members.forEach(member => { // 2. 获取属性名 "name" const name = member.name.getText(); // 3. 获取类型节点 Field<...> if (ts.isTypeReferenceNode(member.type)) { // 4. 提取第二个泛型参数 [MinLen<2>] const rulesArg = member.type.typeArguments?.[1]; // 5. 这里就可以解析出 "MinLen" 和 2 了 console.log(`Field: ${name}, Rules: ${rulesArg.getText()}`); } }); }); } 3. 运行时消费
生成的 JSON 元数据可以直接在运行时使用:
// runtime.ts const schema = { User: { name: { rules: { minLen: 2 } } } }; function validate(data: any) { const rules = schema.User.name.rules; if (rules.minLen && data.name.length < rules.minLen) { throw new Error("Validation Failed: Too short"); } } 这次尝试的核心逻辑其实很简单:用脚本把代码里的类型“抄”出来,存成 JSON ,然后程序运行的时候照着 JSON 执行。
--
本质上,就是把 TypeScript 代码当成配置文件来用。
我只是纯无聊玩玩,如果有大佬想写个小工具什么的。可以放在下面(我懒)。
--
最后,你们在玩 TypeScript 的时候有哪些骚想法?
1 havingautism 5 小 5 分钟前 这个思路很好 |
2 shakaraka PRO |
3 shakaraka PRO |