吃饱了撑的突发奇想: TypeScript 类型能不能作为跑业务逻辑的依据?(纯娱乐)

2025 年 12 月 17 日
 Branlice

吃饱撑的想发:TypeScript 类型能不能用来跑业务呢?(我纯娱乐)

昨天在做业务建模时,看着 TypeScript 的 interface 定义,想到一个问题。

TypeScript 的类型系统在编译后会被擦除( Type Erasure )。这意味着 age: number 这样的约束只存在于开发阶段,运行时完全不可见。

但实际上,这些元数据完整地存在于源码中。如果能写个脚本,在编译时分析源码 AST ,把这些类型信息提取并保存下来,是不是就能在运行时直接复用了?

吃饱了撑的尝试实现了个原型。


1. 从最简单的想法开始

其实最直观的例子,就写的代码里。

interface User {
  posts: Post[];
}

这处理是类型约束,其实也顺便描述了业务关系:User 下面有多个 Post 。

如果不去引用那些额外的装饰器、配置文件,直接复用类型定义来描述关系,是不是也行得通?

顺着这个思路,既然显式的“模型关系”可以从 Post[] 这样的类型结构中直接读出来,那更隐晦的“校验规则”(比如字符串长度、格式限制)是不是也能想办法“寄生”在类型里?

如果能同时把“关系”和“规则”都收敛在类型定义中,并通过编译分析提取给运行时使用,那 interface 就不仅仅是静态检查的工具,而变成了完整的业务逻辑描述。

2. 顺手把关系读出来

既然决定要从类型里提取信息,那先试试最简单的“关系”。

比如 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"
  }
}

这样,模型关系配置就可以直接复用类型定义。

3. 那规则呢?先找个地方藏

关系搞定了,接下来是更复杂的校验规则(如 minLenemail)。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 依然是字符串,该怎么用怎么用,完全不影响开发。但在代码文本里,那个 MinLenBlockList 的标记就留在那儿了。

4. 把规则也读出来

定义好类型载体,下一步就是把这些规则信息也读出来。我查了一下,这里正好可以用 TypeScript 的 Compiler API 来实现。

简单来说,它能把 .ts 文件变成一棵可以遍历的树( AST )。我们写个脚本,遍历所有的 interface。当发现属性使用了 Field 类型时,读取其泛型参数(比如 MinLenadmin),并保存下来。

核心逻辑大概是这样(简化版):

// 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"
      }
    }
  }
}

代码里的信息就被提取出来了存成了清单。

5. 运行时怎么用?

前面的脚本跑完以后,所有这些信息(校验规则 + 模型关系)就都存进了 schema.json 里。

--

有了这个文件,运行时要做的事情就很简单了。

--

程序启动时读取这个 JSON 。当 API 接收到数据时,根据 JSON 里的规则自动执行校验逻辑。

这样就实现了把 TypeScript 的静态类型信息带到运行时使用。

以后新增业务模型,只需要维护一份 interface 定义,校验规则和关系定义都会自动同步生成。

--

6. 简单的验证 Demo

为了验证可行性,写个测试。

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 的时候有哪些骚想法?

3307 次点击
所在节点    程序员
18 条回复
havingautism
2025 年 12 月 18 日
这个思路很好
shakaraka
2025 年 12 月 18 日
你需要的是

https://github.com/marcj/deepkit

https://deepkit.io/en/documentation/runtime-types

我已经在公司多个项目中跑了
shakaraka
2025 年 12 月 18 日
https://github.com/microsoft/TypeScript/issues/47658

顺便附上作者和 ts 官方友好交流的 issues
henix
2025 年 12 月 18 日
TypeORM 不是有基于 reflect-metadata 的 decorator 方案了吗
nilaoda
2025 年 12 月 18 日
定一个通用的结构化文本格式,用来描述数据结构。然后写个脚本或者工具,根据这个文本自动生成前端和后端的模板代码。这样前后端就遵循同一套规则,也能统一做校验和约束。自动生成的代码不上库,每次需要就重新生成一遍就行。
sagnitude
2025 年 12 月 18 日
你是否在找: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;
}
xbisland
2025 年 12 月 18 日
@shakaraka #2 这个好用 推荐+1
Ketteiron
2025 年 12 月 18 日
把建模从 interface 换成 schema 不就行了,与其从类型生成 runtime ,不如限制 runtime 并派生完全相同的类型。
类型生成 runtime 是有正当用途,某些框架例如 Vue 可以通过类型生成 runtime ,但是业务也这么干我感觉是歪门邪道。
这也是一个老生常谈的话题,我觉得所有尝试反射、保留元信息的做法都是错误的。
https://github.com/akutruff/typescript-needs-types
Branlice
2025 年 12 月 18 日
@shakaraka 哈哈哈哈,我就纯娱乐。感谢感谢,您说的这些工具非常好,我之前也有在业务中使用过。
Branlice
2025 年 12 月 18 日
@henix 哈哈哈,非常感谢,我个人娱乐玩玩而已。
Branlice
2025 年 12 月 18 日
@nilaoda 越看越觉得你在说 proto ...,hhhhhhhh
Branlice
2025 年 12 月 18 日
@sagnitude 非常感谢哈,我是个人娱乐玩玩,好奇研究一手。
codehz
2025 年 12 月 18 日
@sagnitude 这套方案不是很行,你一个约束可能要写好几遍(考虑嵌套对象数组),因为 ts 没对 decorator 做任何类型检查,lint 也没有,自己写容易写错不一致,decorator 只存在于运行时基本上还是缺点,毕竟你定义数据库结构,还需要一个运行环境,运行前还得跑 transpiler (毕竟也没 runtime 支持 decorator ),那为何不一开始就直接 compile 解决呢()
别跟我说 decorator 更成熟,现在摆着的就是两个互相不兼容的实现,运行时表现完全不一致,根本不能说是成熟
sagnitude
2025 年 12 月 18 日
@codehz 我 ts 代码是用工具自动生成的,不存在自己写的情况,javabean 也不可能有复杂的数据结构,而且嵌套的对象自己也有自己的约束,递归下去检查就行了,只能说我的应用场景没这些问题
至于只存在于运行时的问题,为啥有问题?,我也不需要运行时的强类型,我只是想在自动编译的 class 下面附加一些 metadata 而已,就好比 ClassA._metadata = {xfzId:{minValue:0}},我加的 metadata 又不是为了 typescript 语法,只是为了附加我自己的数据
至于写错的问题,IDE 可以强制提交前 lint ,transpiler 也不是问题,不是瓶颈就不需要优化,等他变成瓶颈再说吧,不要过早优化
defaw
2025 年 12 月 18 日
那你为啥不用更简单的自定义 dsl 生成 ts 定义,然后直接在自定义 dsl 里想怎么写约束关系就怎么写,比折腾 ts 编译器简单得多
muchan92
2025 年 12 月 18 日
类型定义部分可以实现运行时类型约束,是否满足楼主需求?
https://github.com/rainforesters/imsure
NathanDo
2025 年 12 月 18 日
你是否在找 https://zod.dev/
fy136649111
2025 年 12 月 18 日
让我想起了 typia 这个库 https://github.com/samchon/typia

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1179606

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX