吃饱撑的想发:TypeScript 类型能不能用来跑业务呢?(我纯娱乐)
昨天在做业务建模时,看着 TypeScript 的 interface 定义,想到一个问题。
TypeScript 的类型系统在编译后会被擦除(Type Erasure)。这意味着 age: number 这样的约束只存在于开发阶段,运行时完全不可见。
但实际上,这些元数据完整地存在于源码中。如果能写个脚本,在编译时分析源码 AST,把这些类型信息提取并保存下来,是不是就能在运行时直接复用了?
吃饱了撑的尝试实现了个原型。
1. 从最简单的想法开始
其实最直观的例子,就写的代码里。
typescript
interface User {
posts: Post[];
}
这处理是类型约束,其实也顺便描述了业务关系:User 下面有多个 Post。
如果不去引用那些额外的装饰器、配置文件,直接复用类型定义来描述关系,是不是也行得通?
顺着这个思路,既然显式的"模型关系"可以从 Post[] 这样的类型结构中直接读出来,那更隐晦的"校验规则"(比如字符串长度、格式限制)是不是也能想办法"寄生"在类型里?
如果能同时把"关系"和"规则"都收敛在类型定义中,并通过编译分析提取给运行时使用,那 interface 就不仅仅是静态检查的工具,而变成了完整的业务逻辑描述。
2. 顺手把关系读出来
既然决定要从类型里提取信息,那先试试最简单的"关系"。
比如 posts: Post[]。
在 TypeScript 编译器的视角中,这行代码对应着一个结构严谨的 AST(抽象语法树)节点。
编译器通过 PropertySignature 识别属性名,利用 ArrayType 确定数组结构,并借助 TypeReference 锁定元素类型 Post。这些细粒度的结构化数据(可通过 TypeScript AST Viewer 直观查看)完整保留了代码的语义信息。
核心逻辑在于利用 Compiler API (记录下,他是个强大的工具集,允许开发者像编译器一样"理解"代码。) 遍历 AST:一旦识别到数组类型的属性定义,便将其提取并映射为"一对多"的关系描述。经过转换,源码中的类型定义就被标准化为一份配置 JSON:
json
"relations": {
"posts": {
"type": "hasMany",
"target": "Post"
}
}
这样,模型关系配置就可以直接复用类型定义。
3. 那规则呢?先找个地方藏
关系搞定了,接下来是更复杂的校验规则(如 minLen、email)。TypeScript 本身没有地方直接写 minLen 这种东西,所以好像需要一个载体。
在 TypeScript 的泛型可以是实现一种 Phantom Type(幽灵类型):
typescript
// T 是实际运行时的类型
// Config 是仅编译期存在的元数据
type Field<T, Config> = T;
Field<string, ...> 在运行时就是普通的 string。泛型参数 Config 虽然会被编译擦除,但在 AST 中是可以读取到的。
这样好像就可以在不影响运行时逻辑的前提下嵌入元数据。
看起来像是:
typescript
// 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 的标记就留在那儿了。
4. 把规则也读出来
定义好类型载体,下一步就是把这些规则信息也读出来。我查了一下,这里正好可以用 TypeScript 的 Compiler API 来实现。
简单来说,它能把 .ts 文件变成一棵可以遍历的树(AST)。我们写个脚本,遍历所有的 interface。当发现属性使用了 Field 类型时,读取其泛型参数(比如 MinLen、admin),并保存下来。
核心逻辑大概是这样(简化版):
typescript
// 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,包含了关系和校验规则:
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"
}
}
}
}
代码里的信息就被提取出来了存成了清单。
5. 运行时怎么用?
前面的脚本跑完以后,所有这些信息(校验规则 + 模型关系)就都存进了 schema.json 里。
--
有了这个文件,运行时要做的事情就很简单了。
--
程序启动时读取这个 JSON。当 API 接收到数据时,根据 JSON 里的规则自动执行校验逻辑。
这样就实现了把 TypeScript 的静态类型信息带到运行时使用。
以后新增业务模型,只需要维护一份 interface 定义,校验规则和关系定义都会自动同步生成。
--
6. 简单的验证 Demo
为了验证可行性,写个测试。
1. 类型定义
利用 Phantom Type 携带元数据:
typescript
// 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 提取元数据(简化版):
typescript
// 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 元数据可以直接在运行时使用:
typescript
// 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 的时候有哪些骚想法?