V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
• 请不要在回答技术问题时复制粘贴 AI 生成的内容
Branlice
V2EX  ›  程序员

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

  •  
  •   Branlice · 22 小时 53 分钟前 · 1908 次点击

    吃饱撑的想发: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 的时候有哪些骚想法?

    第 1 条附言  ·  4 小时 53 分钟前
    统一回复,我只是个人好奇。非常感谢各位的推荐,没有需要解决的痛点、也不需要寻求什么解决方案。
    18 条回复    2025-12-18 20:23:04 +08:00
    havingautism
        1
    havingautism  
       19 小时 40 分钟前
    这个思路很好
    shakaraka
        2
    shakaraka  
    PRO
       18 小时 20 分钟前   ❤️ 1
    你需要的是

    https://github.com/marcj/deepkit

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

    我已经在公司多个项目中跑了
    shakaraka
        3
    shakaraka  
    PRO
       18 小时 15 分钟前
    https://github.com/microsoft/TypeScript/issues/47658

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