开头不用承担太多直接进入主题。“t3code”这个名字的来历很简单我取的是 “TypeScript Type To Code” 的缩写——一个把 TypeScript 类型定义转换成项目代码的小工具。做这个工具之前我长期在前后端接口对接里做重复劳动后端改一个字段名前端 interface 要同步改、mock 数据要同步改、数据库建表脚本要改有时候还要改文档。分明只是“类型”层面的变动却要人工贯穿五六个文件少改一处就是线上报错。后来我决定自己写一个工具从类型定义出发把下游重复代码全部自动生成这就是 t3code 的起点。t3code 不是什么宏大框架它只专注一件事把 TypeScript 里定义好的类型结构提取出来通过模板生成指定的目标代码。你可以用它生成前端类型文件、生成 mock 数据、生成 OpenAPI 片段、甚至生成其他语言的结构体。它适合三种人前后端都自己写的全栈开发者、正在维护多端共享数据契约的团队成员、以及对 AST 分析和代码生成感兴趣的技术爱好者。这篇文章我会把 t3code 的设计思路、核心实现、完整使用流程和实际踩过的坑全部拆开讲一遍照着最后一个章节就能跑起来。1. 项目概述与动机1.1 我在什么地方被卡住了事情从一次“用户管理模块”的重构说起。当时项目技术栈是 Vue3 TypeScript 前端Node.js TypeScript 后端前后端共用一套 Git 仓库但分目录维护。一个用户 DTO 长这样// src/domain/dtos/UserDTO.ts export interface UserDTO { id: string; name: string; phone: string; email: string; status: active | disabled | pending; createdAt: Date; }这个类型本身没有任何问题问题在于它扩散出去的下游代码。前端需要重新写一个UserInfointerface字段一一对应mock 数据要再写一遍字段数据库建表 SQL 要手动列一遍字段表单校验规则里又要维护一次状态枚举。刚开始字段少还能忍等到后来加了avatarUrl、lastLoginAt、departmentId这些字段后每次改 DTO 我都要全局搜索“User”开头的文件名挨个同步。最离谱的一次我漏改了 mock 数据里的字段名前端列表页直接渲染出 undefined排查了半小时。后来我尝试过用 OpenAPI 作为中间层后端生成 schema、前端用 openapi-typescript 生成类型但这套链路引入的成本很高后端接口要全部标注响应模型团队还要强制维护 controller 层的装饰器对于内部管理系统来说有点杀鸡用牛刀。我也试过手写脚本用正则抽字段结果被泛型、可选属性、嵌套类型各种边缘情况打得头破血流。几次折腾下来我意识到真正该做的是一个“类型到代码”的最小翻译层输入是 TS 类型输出是模板渲染结果中间用 AST 解析保证准确度边界清晰不绑架整个项目的架构。1.2 t3code 到底解决什么问题拿 t3code 生成后的效果对比可能更直观。同样是改一个 DTO 字段传统流程至少改四个文件t3code 流程只需要改原始类型然后跑一次命令传统方式t3code 方式修改后端 DTO 定义修改后端 DTO 定义搜索所有引用文件手动同步字段执行t3code run检查漏改字段跑测试验证执行t3code check确认一致性提交代码时还要提醒 review 同事检查同步情况生成结果有标记区改动即 diffreview 只看核心逻辑它不是代码生成器全家桶不会帮你写业务逻辑、不会接管路由注册、也不会生成表单组件。t3code 的风格是“只翻译不思考”——你说status是三个字符串字面量的联合类型它就忠实地在目标文件里生成对应的联合类型你说createdAt是Date它就生成string通过映射配置而不是自作聪明换成别的格式。这种克制让它很容易引入到现有项目里不挑框架、不挑目录结构、不强制改造历史代码。作为工具它的核心价值是把“人工同步类型”这件事从不可靠的脑力劳动变成了可验证的机械操作。机器干这种活比人可靠得多而且每次生成结果一致不会有“上次加了字段这次忘了同步”的情况。2. 核心设计与实现细节2.1 两层架构解析层与生成层t3code 整体只有两个核心层解析层负责把 TypeScript 源码变成结构化信息生成层负责把结构化信息渲染成目标代码。中间用一份轻量级的中间表示IR串起来我叫它TypeDescriptor。这个设计是从实际教训里逼出来的。最初版本我直接在解析阶段就拼生成字符串解析和渲染逻辑揉在一起结果每支持一种新输出格式比如从生成 TS interface 改成生成 JSON Schema就要把解析逻辑复制一份再改代码迅速腐化。后来改成两层隔离之后解析层完全不知道下游是什么格式生成层也完全不关心输入来源新增输出格式只需要写一个新的模板渲染函数就行。解析层我用的是 TypeScript Compiler API 的封装库 ts-morph。很多人问为什么不用 Babel 或者正则。正则别问了问就是血泪史。TS 类型语法远比表面看起来复杂一个简单的status: active | disabled | pending用正则勉强能拆但遇到Recordstring, { id: number; list: ArrayItemDTO }这种嵌套泛型正则处理起来就是灾难。ts-morph 的好处是直接基于 TS 官方编译器生态拿到的是结构化的 AST 节点而且能通过类型检查器判断很多“文本看不出来”的问题。生成层则是一个纯模板渲染过程。我把 TypeDescriptor 交给 EJS 模板输出对应的文本文件。选择 EJS 而不是 Handlebars 的原因很实在EJS 里可以直接写 JavaScript 表达式对于条件判断、遍历、管道处理这类逻辑表达更自然不需要为模板引擎单独设计一套 helper API。2.2 类型信息如何被完整地挖出来一个 TS 类型文件里藏着的东西远不止“字段名 类型名”。要生成可靠的下游代码解析层至少要把这些信息全部提取出来字段名、字段类型、是否可选、是否只读泛型参数及参数的具体实例ArrayUserDTO里的UserDTO联合类型的所有分支active | disabled | pending嵌套对象结构的层级关系类型引用关系哪些类型互相import枚举成员及其值我最终的数据模型长这样示例interface TypeDescriptor { kind: interface | typeAlias | enum; name: string; filePath: string; fields?: FieldDescriptor[]; members?: EnumMemberDescriptor[]; typeParameters?: string[]; isExported: boolean; } interface FieldDescriptor { name: string; typeName: string; // 原始类型名称如 UserDTO typeKind: primitive | reference | union | array | object | literal; isOptional: boolean; isReadonly: boolean; nested?: TypeDescriptor[]; // 嵌套对象结构 unionValues?: string[]; // 联合类型的字面量分支 genericArgs?: TypeDescriptor[]; // 泛型实参 }提取这些信息的过程本质就是遍历 AST 节点然后分类。比如遇到PropertySignature节点就读取它的name、questionToken对应可选标记?、typeNode。遇到UnionTypeNode就遍历它的types数组逐个判断分支是字面量类型、引用类型还是嵌套对象。这里有个特别容易被忽略的细节typeName不能直接从文本里截取因为Mapstring, UserDTO[]这个类型名应该是一个结构描述而不是一个字符串。所以我把泛型实参也拆成递归的 TypeDescriptor生成层才能正确地渲染出Array、Map或者把它转成 Go 的map[string][]UserDTO。2.3 模板引擎选择与生成区标记模板这块t3code 默认内置了三个模板ts-interface、ts-type、mock-json但核心机制都在模板之外。真正让代码生成工具可以安全落地的是“生成区标记”机制。所谓生成区标记是在目标文件里用固定注释标记出一块“机器管辖区域”t3code 每次生成只重写这块区域内的内容区域之外保留用户手写的任何代码。举个例子// src/client/types/User.ts import type { DepartmentDTO } from ./Department; // 这段代码是用户自己加的不会被覆盖 export const UserStatusOptions [active, disabled, pending] as const; // T3CODE:START export interface User { id: string; name: string; phone: string; email: string; status: active | disabled | pending; createdAt: string; } // T3CODE:END 第二次运行时t3code 只替换T3CODE:START到T3CODE:END之间的代码上面手写的UserStatusOptions安然无恙。这个设计是我在最初版本吃了大亏之后总结出来的。第一版生成工具每次都会整文件覆盖导致用户手写的辅助函数、业务注释被频繁抹掉团队直接弃用。后来加上标记区意见立刻反转大家才真正愿意在提交 PR 时把生成结果一起带进去。不过标记区也有自己的坑后面实战部分我会专门讲到如果有人删了结束标记或者手动改了生成区内容工具要怎么识别。这里先提一个原则——我不会去校验标记区内代码是否“等于上次生成的代码”因为那会把工具变成一个束缚人的 lint 插件。我选择的是更轻量的策略只在执行t3code check命令时做全量对比日常run命令只要标记还在就直接重写。2.4 命名风格映射与自定义扩展有一个细节容易被新手忽略后端类型习惯用UserDTO、UserCreateRequest这种带后缀的名字前端拿到生成结果后往往希望保留原名但有些团队希望剥掉 DTO 后缀只保留User。不同团队的偏好不同模板直接写死会很难用。t3code 内置了一个nameTransform配置支持keep、stripSuffix、camelCase、pascalCase几种模式。这个配置的默认值是keep我不会替用户做决定。但是有一个场景我强烈建议用stripSuffix——当你在一个目录里既有UserDTO又有UserView时生成端靠上的业务类型往往对应同一个实体剥掉后缀再生成可以方便引用。这里注意剥掉后缀后如果两个类型变成同名t3code 会在命名冲突时给出错误提示而不是静默覆盖这个设计有意的宁可停下来让人改配置也不能在生成结果里埋炸弹。3. 实操过程与核心环节实现3.1 安装与初始化t3code 发布在 npm 上安装方式很常规npm i -g t3code全局安装后在项目根目录执行初始化命令t3code initinit会询问三个问题输入类型文件的 Glob 路径、默认输出目录、常用模板类型。回答完成后生成一个t3code.config.json目录结构大概是project-root/ ├── src/ │ └── domain/dtos/ │ ├── UserDTO.ts │ └── RoleDTO.ts ├── src/client/ │ └── types/ # t3code 输出目录 ├── t3code.config.json └── t3code-template/ # 自定义模板目录可选如果你不想全局安装也用npx t3code init跑效果一样。初始化这一步会花掉大约一分钟核心就是把输入路径和输出路径确定下来。3.2 配置文件逐字段拆解t3code.config.json是工具的核心配置我逐个字段说一下因为很多人照着示例写还是会踩坑{ input: [src/domain/dtos/**/*.ts], outputDir: src/client/types, templates: [ts-interface], templateDir: ./t3code-template, nameTransform: { mode: stripSuffix, suffixes: [DTO, Request, Response], exclude: [PageResult, ApiResponse] }, enumStyle: union, typeMapping: { Date: string, Buffer: string, ObjectId: string }, marker: { template: T3CODE:{{action}}, enable: true }, format: { indentSize: 2, semicolon: true }, watch: { enabled: true, debounceMs: 300 } }input是 Glob 表达式数组默认只匹配.ts文件不匹配.d.ts。这个限制在实现时考虑过.d.ts里通常有全局声明混进解析器容易产生大量无关类型。templates和templateDir的关系要注意templates里写的是内置模板名如果templateDir存在同名模板文件则优先使用自定义模板。模板文件是一份.ejs文件里面用% %写逻辑通过全局变量descriptor拿到当前类型的完整结构。nameTransform的exclude数组是很多人忽略但很有用的配置。它用于排除某些类型名不让名字转换逻辑生效。比如PageResultT泛型容器剥掉DTO后缀会变成PageResult本身没问题但如果模板里对这个泛型容器有专门的渲染分支你就不希望它被改名。这个字段就是用来声明“所有匹配这些名字的类型一律保持原名”的。enumStyle: union表示枚举生成风格。TS 枚举实际编译后是一个对象直接序列化接口时通常输出数字但很多前端团队在接口对接时更想要字符串字面量。所以 t3code 支持两种风格enum表示生成标准 TS 枚举union表示生成字符串联合类型。我推荐union后面常见问题里会展开解释为什么。typeMapping是类型映射层用于处理 TS 类型和下游类型不一致的问题。Date映射成string是最典型的场景因为在 JSON 序列化里 Date 本身就是字符串前端 interface 写Date反而容易在反序列化时出错。3.3 从 DTO 走到两端代码的完整流程第一步先定义后端类型注意export关键字必须加t3code 只会解析带export的类型声明// src/domain/dtos/UserDTO.ts export interface UserDTO { id: string; name: string; phone: string; email: string; status: active | disabled | pending; createdAt: Date; }第二步执行生成命令t3code run默认情况下控制台会输出本次生成的摘要解析了多少个类型、生成哪些文件、耗时多少毫秒。run命令还支持两个常用参数t3code run --dry-run # 只打印将要写入的文件不实际写盘 t3code run --verbose # 打印每个类型的解析详情用于排查问题第三步检查生成结果。以ts-interface模板为例src/client/types/User.ts会生成如下内容// T3CODE:START export interface User { id: string; name: string; phone: string; email: string; status: active | disabled | pending; createdAt: string; } // T3CODE:END 注意类型名的变化UserDTO默认配置下被转换成了User这就是上一节说的nameTransform.stripSuffix在起作用。createdAt从Date变成了string这是typeMapping的映射。如果配置里把nameTransform.mode改成keep生成的就是export interface UserDTO。第四步如果你用的是 mock 模板可以单独生成 mock 数据文件t3code run --templates ts-interface mock-jsonmock 数据的生成基于类型结构status会从联合类型里随机取一个分支id会生成 uuidcreatedAt会生成当前时间字符串。生成的 mock 函数通常长这样简化版export function generateUserMock(): User { return { id: b7e5f3a2-..., name: MockName_80912, phone: 138****1234, email: mock_12example.com, status: active, createdAt: 2025-01-15T10:30:00.000Z, }; }mock 模板我后来用得很多前端在接口未就绪时完全可以用它当数据源开发 UI接口一好就切掉过渡非常平滑。3.4 监视模式改一处自动刷新全部下游文件手动跑命令虽然比人工同步快得多但人还是会忘。所以 t3code 提供了一个watch命令它做的事情和run一样但会持续监听input目录下的文件变化t3code watchwatch内部实现的核心是文件内容哈希监听不是简单监听文件修改事件。这么设计的原因很直接IDE 自动格式化有时会批量触发文件保存事件或者git pull之后一批文件同时变更如果每个事件都跑一次全量解析CPU 会瞬间飙高且输出内容抖动。所以watch模式会先对每个输入文件计算 hash只有当 hash 变化时才判定“这个文件值得处理”再配合配置里的debounceMs: 300做防抖确保一批连续改动只触发一次生成。实测下来一个包含 200 个类型文件的项目连续改动时 watch 模式的响应时间稳定在 400ms 左右体感上几乎是无感的。这里有个建议在 CI/CD 环境里不要用watch模式而是用t3code check命令做校验。因为 watch 模式是开发期的交互工具check 才是机器可执行的校验命令。4. 常见问题与排查技巧实录4.1 类型解析失败的几种经典报错用 AST 解析类型比想象中容易翻车尤其你最自信的时候。我整理过几个高频出错场景以及对应的处理方式。第一种是解析不了import type。t3code 默认不解析没有export的类型但一个类型文件里可能同时有import type { UserDTO } from ./UserDTO;和export interface TeamDTO { members: UserDTO[] }。如果解析器不认识这个UserDTO引用它在构造 IR 时就会找不到UserDTO的定义。解决方案是配置里增加一个includeDependencies字段为true让解析器自动把 import 进来的类型也纳入解析队列。第二种是自引用类型导致死循环。比如export interface TreeNodeDTO { children: TreeNodeDTO[]; value: string; }这个类型引用了自身如果解析器不处理循环检测递归解析会直接爆栈。实现时我在解析器内部维护一个visited集合每个类型名只展开一次。这个工作在代码生成层面尤其重要——生成 TS interface 时自引用是安全的但生成 mock 数据时如果没有深度限制会无限递归生成嵌套对象。所以 mock 生成器里我加了一个默认深度上限 3 层超过上限直接用空数组或 null 代替。第三种是联合类型里嵌套对象引用解析失败。比如status: active | { [key: string]: string }这种结构混搭了字面量类型和索引签名类型早期版本会直接跳过整个字段。后来实现了递归分类才把联合类型拆解到叶子节点逐一识别。4.2 枚举序列化格式到底该选哪个很多团队的接口文档里写着status字段是枚举TS 里定义如下export enum UserStatus { Active 1, Disabled 2, Pending 3, }如果生成端直接输出这个枚举前端拿到数字 1、2、3可读性很差而且后端如果调整枚举序数前端完全无感知。这就是为什么我默认推荐enumStyle: union。改成 union 后在生成文件里体现为export type UserStatus active | disabled | pending;但注意这么做的前提是后端接口实际返回的是字符串形式而不是枚举的数字值。有些后端框架会自动把枚举序列化成字符串比如 NestJS 的ApiProperty({ enum: UserStatus })这种场景用 union 完全没问题如果接口返回的是数字那你应该保持enumStyle: enum并且最好在配置里把数字枚举值显式写在生成结果里export enum UserStatus { Active 1, Disabled 2, Pending 3, }两种风格没有绝对的对错取决于接口的实际契约。这个判断必须由使用者来定工具只能提供配置项不能替业务决策。4.3 生成区标记被破坏的兜底策略标记区机制很实用但它有一个脆弱点如果有人不小心删掉了// T3CODE:END 这行注释整个文件的生成区就永远不会被重写。因为工具靠结束标记定位区间找不到完整的标记对时安全策略是跳过该文件并在控制台输出 warning而不是贸然重写。更隐蔽的问题是用户手动修改了生成区里的代码。比如把生成的Userinterface 里多加了一个token?: string字段下次run时这个字段会被静默抹掉。这种“工具抹掉手写改动”的情况最容易引发团队矛盾。我的处理方式是增加了一个可选的check校验模式t3code check命令会解析标记区内当前内容与预期生成结果做逐行 diff。如果发现不一致命令退出码设为 1并在 CI 日志里打印差异。团队可以约定开发本地随意改提交 MR 前必须t3code check通过。也就是把最终一致性校验交给 CI让机器在合并前兜底。4.4 性能优化目录变大之后如何保持秒级生成200 个类型文件、每个文件有 50 个字段的项目规模下全量解析需要大约 2 到 4 秒。这个耗时如果每次保存都触发会很烦躁。watch模式下的优化方法前面已经提过按内容 hash 只处理变更文件。run命令里我则实现了文件级缓存.t3code-cache.json文件记录了上次每个输入文件的 hash 以及对应的输出文件名。第二次运行run时如果 hash 没变且输出文件存在就跳过解析。这个缓存机制在多数项目里能把 4 秒压缩到 800ms 左右。还有一个容易忽略的性能陷阱是模板引擎的字符串拼接。如果生成的是一个非常大的类型文件EJS 模板里频繁使用% %拼接字符串会产生大量中间对象。我自己踩过这个坑一个 3000 行的生成结果用简单模板跑了 1.2 秒优化成先 push 到数组再join(\\n)之后降到 200ms。这个优化不必让用户感知但如果你要自定义模板建议用数组收集行而不是不断字符串相加。4.5 同目录类型同名冲突当输入目录较大时很可能出现两个文件都声明了UserDTO但代表不同的业务对象。比如src/admin/dtos/UserDTO.ts和src/user/dtos/UserDTO.ts。默认情况下生成后的文件名都是User.ts第二个文件会把第一个覆盖掉。解决这个问题我从一开始就留了余地t3code 会检查生成目标的完整路径是否冲突一旦发现两个不同源文件映射到同一个输出文件就报错并列出来源。用户可以配置outputStrategy为preserveDir让输出目录保留输入目录的相对层级{ outputStrategy: preserveDir }这样生成结果会分布在src/client/types/admin/User.ts和src/client/types/user/User.ts物理隔离互不干扰。这个配置对大型中后台项目几乎是必备的强烈建议从一开始就按目录结构输出不要为了省路径层级把所有类型摊平。5. 使用心得与后续扩展5.1 在真实项目里接进来的三个注意点第一t3code 生成的文件要提交进 Git不要加进.gitignore。有人为了保持仓库干净把生成文件全部忽略掉结果 CI 上每次都要重新生成而且开发者本地没生成时 IDE 大量报红。生成文件也是代码的一部分提交它可以让 diff 可见、review 可查这也是为什么标记区设计里要把“代码改动可 diff”作为第一优先级。第二模板目录要跟业务团队约定清楚。自定义模板放哪些目录、命名规范是什么、谁有权限改这些看起来是小事但在多人协作时会产生各种“为什么我的类型没有生成出预期结果”的疑问。我的建议是模板目录只放项目特有的模板内置模板永远不动。第三接入新项目时先从“只生成不覆盖”开始。我刚接入第一个团队时只跑t3code run --dry-run让开发同事看到生成结果、确认符合预期再开启真写入。开发习惯靠引导不靠强推先用工具帮他们省一次人工同步的精力他们自己就会离不开。5.2 从单机工具走向规范化检查后续版本做得最有价值的一个功能是t3code check的 CI 集成。具体流程是这样的# .gitlab-ci.yml 或 GitHub Actions 片段 - npm i -g t3code - t3code checkcheck退出码非 0 时流水线失败。这意味着以后任何人在后端 DTO 里改了字段、忘了重新生成前端类型PR 都合不进去。这个机制把 t3code 从“生成器”升级成了“契约校验器”。团队合作时最常见的矛盾不是没人改代码而是改的时候没人意识到“这里还有一处需要改”。CI 检查能强制形成肌肉记忆比任何文档都有效。另外我在规划里还加入过t3code diff命令用途是可视化对比当前生成结果与“上次正确结果”的差异方便开发者快速定位自己改了什么、哪些是工具自动改的。不过这个功能后来被check输出信息的增强给替代了因为大多数场景下开发者只想知道“哪里不一致”diff 细节在 CI 日志里打印即可。5.3 还能往哪些方向延伸t3code 的解耦结构解析层 IR 模板层决定了它能比较轻松地扩展输出目标。目前社区里用的人自发写了几个模板包括 Go struct 生成模板、JSON Schema 输出模板、Java DTO 模板。我不打算把官方模板范围扩得太大因为类型映射的规则高度依赖业务场景官方维护太多内置模板反而会变成四不像。我自己比较看好一个方向从 OpenAPI 文档反向生成 TS 类型然后交给 t3code 做二次加工。很多后端团队已经有 OpenAPI 文档基础设施那是一条现成的类型来源。解析层只要增加一个openapi-v3输入适配器把 schema 对象转成 TypeDescriptor下游模板完全不用改。这是 t3code 最自然的扩展路径它把“类型输入”和“代码输出”彻底隔离每一端的独立性都保住了。再说一个技巧如果你希望 t3code 生成的代码在编辑器里有完整的类型提示建议在生成的 TS 文件头部加一行/* eslint-disable */或者通过模板参数传入ts-nocheck指令。这不是必须的但可以避免生成的代码因为 lint 规则被一堆红线标注影响同事阅读真实业务代码。最后聊一点我的个人感受。做一个代码生成工具最重要的不是“能生成多少代码”而是“生成的代码是否值得被信任”。t3code 的设计始终围绕“可预期、可校验、可保护手写代码”这三个关键词。解析失败时宁可报错也不猜测检测到标记区被破坏时宁可跳过也不覆盖生成结果一律走 diff 审查。这种纪律性让工具从“花活”变成了“基建”。如果你也在考虑做类似的小工具我唯一的建议是早点把“生成区保护”和“命令退出码”设计进核心机制里这两个东西比任何酷炫的模板都重要得多。