
昨天在做业务建模时,看着 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 的时候有哪些骚想法?
nbsp; 1 havingautism 21 小时 2 分钟前 这个思路很好 |
2 shakaraka PRO |
3 shakaraka PRO |
4 henix 13 小时 47 分钟前 TypeORM 不是有基于 reflect-metadata 的 decorator 方案了吗 |
5 nilaoda 13 小时 7 分钟前 定一个通用的结构化文本格式,用来描述数据结构。然后写个脚本或者工具,根据这个文本自动生成前端和后端的模板代码。这样前后端就遵循同一套规则,也能统一做校验和约束。自动生成的代码不上库,每次需要就重新生成一遍就行。 |
6 sagnitude 13 小时 7 分钟前 你是否在找:reflect-metadata 类型定义: const MetaKeyMin = 'min'; export const Min = (min: any): PropertyDecorator => { return (target, key) => { Reflect.defineMetadata(MetaKeyMin, min, target, key); } } 使用: export class EquipVO { @Min({value:0}) xfzId?: number; } 运行时读取: export function CheckObjectFiledByKey(object: object, key: string): string { var hasMin = Reflect.hasMetadata("min", object, key); var min = Reflect.getMetadata("min", object, key); // value: min.value } 当时做这套功能就是为了保证源代码和 java 基本一致,并且支持基本的 validation-api 功能,这样可以用工具生成 ts 代码 @Data public class Equip extends SecModel { @Min(value = 0) private Integer xfzId; } |
8 Ketteiron 12 小时 41 分钟前 把建模从 interface 换成 schema 不就行了,与其从类型生成 runtime ,不如限制 runtime 并派生完全相同的类型。 类型生成 runtime 是有正当用途,某些框架例如 Vue 可以通过类型生成 runtime ,但是业务也这么干我感觉是歪门邪道。 这也是一个老生常谈的话题,我觉得所有尝试反射、保留元信息的做法都是错误的。 https://github.com/akutruff/typescript-needs-types |
13 codehz 12 小时 20 分钟前 via Android @sagnitude 这套方案不是很行,你一个约束可能要写好几遍(考虑嵌套对象数组),因为 ts 没对 decorator 做任何类型检查,lint 也没有,自己写容易写错不一致,decorator 只存在于运行时基本上还是缺点,毕竟你定义数据库结构,还需要一个运行环境,运行前还得跑 transpiler (毕竟也没 runtime 支持 decorator ),那为何不一开始就直接 compile 解决呢() 别跟我说 decorator 更成熟,现在摆着的就是两个互相不兼容的实现,运行时表现完全不一致,根本不能说是成熟 |
14 sagnitude 11 小时 37 分钟前 @codehz 我 ts 代码是用工具自动生成的,不存在自己写的情况,javabean 也不可能有复杂的数据结构,而且嵌套的对象自己也有自己的约束,递归下去检查就行了,只能说我的应用场景没这些问题 至于只存在于运行时的问题,为啥有问题?,我也不需要运行时的强类型,我只是想在自动编译的 class 下面附加一些 metadata 而已,就好比 ClassA._metadata = {xfzId:{minValue:0}},我加的 metadata 又不是为了 typescript 语法,只是为了附加我自己的数据 至于写错的问题,IDE 可以强制提交前 lint ,transpiler 也不是问题,不是瓶颈就不需要优化,等他变成瓶颈再说吧,不要过早优化 |
15 defaw 10 小时 23 分钟前 那你为啥不用更简单的自定义 dsl 生成 ts 定义,然后直接在自定义 dsl 里想怎么写约束关系就怎么写,比折腾 ts 编译器简单得多 |
16 muchan92 9 小时 45 分钟前 via Android 类型定义部分可以实现运行时类型约束,是否满足楼主需求? https://github.com/rainforesters/imsure |
17 NathanDo 9 小时 28 分钟前 你是否在找 https://zod.dev/ ? |
18 fy136649111 2 小时 28 分钟前 让我想起了 typia 这个库 https://github.com/samchon/typia |