t3code:基于模型描述自动生成TypeScript类型与API请求代码 1. 项目概述与核心思路1.1 t3code 解决的是什么问题先说我为什么会做 t3code 这个项目。过去几年我做 Web 开发大部分时间都在跟 TypeScript 打交道接触了不少前后端一体的项目。最让我头疼的永远是一件事接口类型在前后端之间对不上。后端改了字段名前端跑着跑着就报 undefined等发现问题的时候已经过去好几个小时了。这种问题不是偶发而是每天都在发生。t3code 是我写的一个命令行代码生成工具核心功能是从后端接口定义或数据库模型声明中自动生成前端可用的类型定义、API 请求函数、Mock 数据以及 Zustand 状态管理模板。举个例子你在后端定义了一个 User 模型包含 id、name、email 三个字段t3code 就会自动生成对应的 TypeScript interface、一套带类型约束的 fetch 请求函数以及一份可直接丢给前端联调的 Mock 数据。这东西适合谁用主要是三类人一是被前后端联调折磨的全栈开发者二是团队里接口文档经常滞后、前端要追着后端问字段的协作场景三是一个人维护多个端Web、小程序、管理后台的独立开发者。它解决的核心痛点是类型的一致性让类型定义只维护一份其他全部自动生成从根源上减少联调期低效沟通。1.2 为什么选择代码生成这条路有人可能会问现在类型安全方案这么多tRPC、GraphQL Code Generator、Prisma 都能做类似的事为什么还要自己写一个我的判断是这样的tRPC 很好但它要求前后端都用 TypeScript 且服务端严格基于 tRPC 体系GraphQL Code Generator 也很好但引入 GraphQL 对整个项目来说是个不小的架构变更。在很多遗留项目里后端是 Java、Node 或者 Python前端是 React 技术栈你不可能为了类型安全去重写整个后端。t3code 的定位是与既有技术栈解耦它只需要一份中间描述文件JSON / YAML不管后端是 Spring Boot、NestJS 还是 Express只要能产出这份描述文件前端就能自动生成代码。这套理念很像写 SQL 迁移脚本只是它生成的是类型和请求层代码。另一个考虑是可控性。代码生成工具有一个不好的名声因为很多生成器生成的代码质量差、不可读、改起来想砸键盘。我在设计 t3code 时把生成出来的代码必须像人写的当作最优先原则。模板引擎里的每个模板都是我自己手写的生成出来的代码自带 JSDoc 注释、有合理的空行和缩进、命名风格统一。你在 IDE 里看这些生成的代码不会有一种这是机器拉的屎的违和感。1.3 整体架构设计整个工具分为三个层次描述层一份 model.yaml 或 model.json声明所有模型、接口、枚举。这是唯一需要人维护的文件。解析层t3code 读取描述文件解析模型之间的关系一对一、一对多、字段类型、必填属性、注释信息并构建一个内存中的中间表示IR。生成层基于 IR 和内置的 Handlebars 模板按不同目标类型types、api、mock、store分别渲染输出到指定目录。用一张最直白的话说t3code 的本质就是一个类型读取器加上一堆代码渲染器。你给它一份结构化的数据它还你一整套可以直接跑起来的前端代码。后面的章节我会把每个环节怎么设计、怎么实现、踩了哪些坑都展开讲包括关键参数怎么计算、模板组织逻辑、以及删除旧文件时的一个大坑。2. 核心实现拆解与工具选型2.1 入口参数与命令设计我先说 CLI 层怎么设计的因为这是所有用户第一个接触到的东西。t3code 提供了四个主要命令# 初始化项目配置生成 t3code.config.ts t3code init # 构建描述文件从数据库/手写model生成 t3code build # 生成目标代码 t3code generate # 监听模式描述文件变化后自动重新生成 t3code watch为什么要把 generate 单独拆出来而不是 init 后自动执行因为实际项目中描述文件不一定是一次性到位用户需要反复调整。生成动作应该是显式的、可预期的行为而不是某个命令的副作用。这个设计原则同样体现在参数设计上--dry-run只打印将要生成的文件列表不落盘方便预览改动。--clean删除目标目录下由 t3code 生成过、但当前描述文件中已不存在的旧文件。--format生成后自动执行 prettier保证代码风格统一。参数越少越好这是 CLI 设计的第一原则。t3code 绝不提供十几个 flags能用配置文件表达的全部放到配置里。配置文件长这样import { defineConfig } from t3code; export default defineConfig({ input: ./models/model.yaml, outputs: { types: { path: ./src/generated/types.ts, format: prettier, }, api: { path: ./src/generated/api.ts, include: [user, order], exclude: [internal], }, }, });这里有个细节值得说明outputs 的设计不是简单的目标路径到模板类型的映射而是允许每个输出目标单独配置 include 和 exclude。因为在大型项目里你可能只想为部分模型生成 API 代码其他的只生成类型。比如订单模块生成完整请求函数而字典类型只生成 interface避免生成一堆用不到的代码。2.2 描述文件与类型建模描述文件的格式一开始我纠结了很久JSON 还是 YAMLJSON 的好处是严格、没有缩进歧义、所有语言都能解析YAML 的好处是写起来舒服、注释友好、更适合人手工维护。最终决定默认支持 JSON同时兼容 YAML解析层统一转成同一份 IR。底层用了 js-yaml 做 YAML 解析用 JSON Schema 对描述文件做合法性校验错误信息直接指出第几行第几个字段错了而不是甩一段堆栈。类型设计是最核心的部分。下面是一份真实的模型描述文件示例model: User table: users fields: - name: id type: string format: uuid primary: true description: 用户ID - name: email type: string format: email required: true description: 登录邮箱 - name: age type: number required: false min: 0 max: 120 description: 年龄 - name: status type: enum enum: [active, disabled, pending] default: active description: 用户状态 - name: createdAt type: datetime description: 创建时间这里有两个关键设计点类型字段不只存类型还存 formatuuid / email / datetime 等、约束条件min / max / pattern、默认值、描述。这样生成层可以做更智能的事情比如得知 createdAt 是 datetime自动生成dayjs格式化函数得知 email 格式生成随机邮箱 Mock 数据时就能生成user_xxxexample.com。模型名称默认转 PascalCase字段名转 camelCase表名转 snake_case 用于数据库关联。这个转换不是简单字符串替换而是解析了常见的命名风格snake_case、kebab-case、camelCase、PascalCase保证无论描述文件里写成哪种输出代码都是统一风格。我还定义了一个叫 Relation 的模型用来说明模型之间的关系relations: - from: User type: one-to-many to: Order foreignKey: userId as: orders这段描述的意思是User 模型对应多个 Order 模型外键字段是 userId在 User 的生成类型中会多出一个orders: Order[]属性。生成 API 代码时会自动生成fetchUserOrders(userId)这类方法。靠描述文件就能表达完整的数据模型关系这在只使用 REST API 的项目里是很大的便利。2.3 模板引擎为什么选 Handlebars 而不是 EJS模板层的选型我做过对比。EJS 功能更直接直接在 HTML 里写 JavaScript灵活但容易把逻辑写在模板里模板越来越难维护。Handlebars 的哲学是模板无逻辑一切数据预处理好之后传入模板模板只做展示。这两种选择的核心差别在于当模板越来越复杂的时候EJS 的模板最后会变成用另一种语法写面条代码而 Handlebars 因为强制逻辑外置模板结构会越发清晰。t3code 最终采用 Handlebars 加一组自定义 helper。生成的类型模板核心部分大致如下{{#each models}} export interface {{name}} { {{#each fields}} /** {{description}} */ {{name}}{{#unless required}}?{{/unless}}: {{typeName}}; {{/each}} {{#each relations}} /** 关联{{type}} */ {{name}}: {{targetTypeName}}{{#unless isRequired}} | null{{/unless}}; {{/each}} } {{/each}}逻辑外置带来的直接好处是模板可被彻底测试。我把所有 helper如typeName的转译单独写成纯函数写了几十个单元测试重点测边界情况比如unknown 类型怎么转嵌套模型怎么处理jointype 多对多怎么展开。模板本身则通过 snapshot 测试锁定输出任何生成结果变化都会被 CI 捕获。2.4 类型映射表的设计后端类型和前端类型并不是一一对应的。比如后端的datetime在纯前端类型里对应string但如果你选择了 dayjs 适配器它会变成Dayjs后端的decimal类型在 TypeScript 里要么是number要么是string为了避免精度丢失很多金融项目会把 decimal 序列化成 string。t3code 的做法是内置一套默认映射表同时允许用户自定义覆盖描述文件类型TypeScript 默认类型可选适配器类型备注stringstringstring无特殊处理numbernumbernumber无特殊处理integernumbernumber生成校验时会加 Number.isIntegerbooleanbooleanboolean无特殊处理datetimestringDayjs选择 Dayjs 适配器后自动导入 dayjsuuidstringstring生成 Mock 时自动生成随机 UUIDemailstringstring生成 Mock 时自动生成带域名的邮箱enum联合类型字符串枚举对象可配置生成 enum 常量对象jsonRecordstring, unknownany尽量使用 Record 避免 anyarrayT[]T[]需要配合 items 字段decimalstringnumber默认 string防精度丢失objectRecordstring, unknown嵌套 interface对象嵌套这个表不是死的用户在配置里可以这样覆盖typeMap: { decimal: number, datetime: Dayjs, }还有一个细节不知道有没有人注意到枚举类型的处理。后端的枚举在 TypeScript 里其实有两种表达方式原生enum和字符串字面量联合类型。我更推荐字符串字面量联合类型因为它天然支持as const模式且在类型层面更安全。t3code 默认生成联合类型但用户可以通过emitEnum: const选择生成 const enum 对象。坦白说我个人的实践里90% 的场景用联合类型就够了。3. 实操过程与核心环节实现3.1 从零初始化一个项目下面我完整走一遍 t3code 的使用流程这样你能看到整个工具在实际项目里是怎么落地的。第一步安装工具npm install -D t3code第二步初始化配置文件npx t3code init这条命令会在当前目录生成t3code.config.ts和一份示例models/model.yaml。init 的核心设计是生成可运行的默认配置而不是给你一份全是注释的空文件。也就是说init 完成后你立刻执行npx t3code generate它就能基于示例模型生成代码你能马上看到效果再逐步改成自己的模型。第三步修改配置指定你的模型文件和输出路径。这里我建议第一次使用的人输出到./src/generated目录并且把该目录加进.gitignore。生成的代码应该始终被视为可再生的构建产物而不是需要被手工维护的源码。唯一例外是你对生成结果做了定制化修改但这种情况我建议优先修改模板而不是直接改生成后的代码。3.2 一份完整模型描述与生成示例我分享一个实际项目中的案例。我有一个电商后台涉及用户、订单、商品、支付记录四个核心模型。描述文件的核心内容大概长这样model: Order table: orders fields: - name: id type: string format: uuid primary: true - name: userId type: string format: uuid required: true description: 下单用户ID - name: amount type: decimal required: true description: 订单金额单位元 - name: status type: enum enum: [pending, paid, shipped, completed, cancelled] default: pending description: 订单状态 - name: items type: array items: type: object properties: - name: productId type: string - name: quantity type: integer - name: price type: decimal description: 订单明细 relations: - from: Order type: many-to-one to: User foreignKey: userId as: user生成后的类型代码节选如下/** * 订单 */ export interface Order { /** 订单ID */ id: string; /** 下单用户ID */ userId: string; /** 订单金额单位元 */ amount: string; /** 订单状态 */ status: pending | paid | shipped | completed | cancelled; /** * 订单明细 */ items: OrderItem[]; /** 关联用户 */ user: User | null; } export interface OrderItem { productId: string; quantity: number; price: string; }注意几个细节amount因为 decimal 默认生成string这是为了防止精度丢失status生成的是联合类型items里的嵌套对象被自动提取成独立的OrderIteminterface而不是内联在 Order 里这样代码可读性更好且 OrderItem 也可以在其他地方复用。API 代码生成是另一个关键环节。同一份描述文件会生成/** * 获取订单列表 */ export async function fetchOrderList(params: FetchOrderListParams): PromiseOrder[] { const query buildQueryString(params); const response await http.get(/api/orders?${query}); return response.data; } /** * 创建订单 */ export async function createOrder(data: CreateOrderInput): PromiseOrder { const response await http.post(/api/orders, data); return response.data; }FetchOrderListParams 和 CreateOrderInput 也是自动生成的。前者会根据模型的查询需求生成分页参数page、pageSize、排序参数sortBy、order、以及所有支持过滤的字段后者会根据 Order 模型中创建时必填的字段生成 Input 类型比如 id 和 status 不会出现在 CreateOrderInput 里因为它们是服务端生成的。3.3 模板编写的关键步骤如果你要扩展 t3code 的模板或者在自己的项目里基于同样的思路写生成器我讲讲模板编写时的核心流程。先写类型模板是最简单的切入点。从已有的模型描述入手生成interface定义。核心变量只有models和relations两个一般一个下午就能搞定。等你发现每个模型生成的 API 方法存在重复逻辑时再写 API 模板。这里有两个原则按方法名 参数 返回类型的最小结构写模板。不要试图在模板里面写具体请求逻辑应该生成一个调用http模块的函数外壳。把请求参数拼接、错误处理这些留在独立封装的 http 模块里这样生成的代码天然和项目的请求层解耦。配置优先于模板条件判断。如果某个模型不需要生成 API 代码不要指望模板里写{{#if generateApi}}而应该在配置阶段就把该模型从 API 生成的模型列表中剔除。模板里条件分支越多测试覆盖越难模板越难维护。Mock 数据模板是我自己最满意的一个模块。它根据每个字段的类型、format 和约束条件生成模拟数据。id 走uuid生成email 走user_${random}example.comenum 从枚举值里随机选一个datetime 在当前时间前后随机偏移。数字类型的 min 和 max 会被转换为faker.datatype.number({ min, max })。这样前端开发时能拿到真实形状的数据而不是手写死的 Mock。3.4 引用关系处理与生成顺序这里有一个很多模板生成器容易踩的坑模型之间互相引用时的生成顺序。假设 User 引用了 OrderOrder 又引用了 User如果直接遍历 models 生成代码你会在代码里看到一个Order 接口在被使用之后才定义的问题。当然 TypeScript interface 本身有 hoisting不会报错但代码可读性很差而且某些工具链比如 typedoc 生成文档会受影响。我的解决方案是先做拓扑排序再决定模型生成顺序。t3code 内置了一个简单的有向图检测器从所有模型的 relation 定义中构建依赖图。如果发现循环引用比如 A 引用 B、B 引用 A就标记这两个模型都是需要延迟处理的在生成时把它们放到文件末尾。如果只有一个方向的引用拓扑排序会保证被依赖的模型先出现。这个逻辑虽然简单但避免了很隐蔽的生成问题。还有一个细节是循环引用的类型深度。比如 User 有orders: Order[]Order 又回指向user: User如果不加任何控制TypeScript 类型本身没问题但生成的 Mock 数据可能会无限递归。所以 t3code 在生成 Mock 时对每个模型加了一个固定深度默认是 2 层之后只输出null或者空数组不会无限递归下去。这个深度值可以配置但一般不建议调大因为前端 Mock 数据本来就不需要那么精确。4. 常见问题与排查技巧实录4.1 类型不一致生成出来的类型和接口文档对不上这是用户反馈最多的一类问题。表现是t3code 生成出来status是一个字符串联合类型但接口文档里写的却是数字枚举0 | 1 | 2。这种问题我排查过很多次原因基本都是描述文件的类型定义和真实接口不一致。排查思路是这样先打开描述文件确认字段类型是 enum 还是 number再打开接口文档看实际返回的 JSON 是什么样。很多时候不是 t3code 的问题而是描述文件被人改过或者本身写错了。我会在生成代码的顶部自动生成一段注释注明此文件由 t3code 在 2024-xx-xx 生成源文件 models/model.yaml方便你快速回溯。为了避免这类问题我强烈建议把描述文件纳入 Code Review 流程。它本质上就是一份数据契约比接口文档更精确。你可以写一个 CI 检查脚本在描述文件变更时自动触发t3code generate并检查 git diff 中生成代码是否有意外变动。如果有说明有人改了描述文件但没有同步更新代码直接拦截合并请求。4.2 路径分隔符在 Windows 上导致生成失败这个问题是我在跨平台测试时发现的。当输出路径写的是./src/generated/types.ts时在 Windows 上可能被处理成.\src\generated\types.ts。单看还没有问题但如果模板里用这个路径去拼目标目录就可能生成出src\generated\types.ts.ts或者路径深度不对。解决方法是所有内部路径处理统一用 Node 的path模块而不是字符串拼接。我在工具内部定义了一个normalizeOutputPath函数所有输出路径都经过path.resolvepath.relative处理再传给 mkdir 和 writeFile。这个问题本身不复杂但很容易被忽略尤其是在 Linux 上开发、部署到 Windows CI 环境的时候才会暴露。4.3 生成后的代码被 prettier 重排成不可读的样子这个问题有点微妙。t3code 支持生成后自动执行 prettier但 prettier 的默认配置可能和你项目里的配置冲突。最典型的情况是缩进宽度不同或者单引号双引号不同。如果你项目根目录有.prettierrct3code 会优先读它这能解决大部分问题。但如果你的项目里同时有多个 prettier 配置文件比如 monorepo 中每个包都有自己的配置t3code 生成代码到某个包目录时可能会错误地读取到另一个包的配置。我的建议是在 t3code 配置里显式指定一个.prettierrc路径或者生成后不执行 prettier而是在项目的 git hook 里统一执行prettier --write。这样所有文件包括生成的代码会按一套规则格式化不会有特殊例外。如果你发现生成代码的引号和项目其他文件不一致这不是 t3code 的 bug而是格式化工具链的配置问题照着这个思路排查就行。4.4 旧文件残留删掉的模型还在代码里还有一个高频问题你从描述文件里删除了一个模型执行 generate 之后生成的 types.ts 里确实没有这个模型了但 API 文件里还有残留的请求函数代码。原因是文件缓存。我在早期版本中采用读旧文件、做 diff、只写变化的文件的增量策略。听起来先进但在实际项目里非常容易出错因为 diff 的依据是文本内容一旦模板或格式化工具升级所有文件都会被认为有变化增量生成退化为全量生成反而可能产生一堆无关 diff。后来我把策略改成了全量重写目标文件。每次 generate 都完整地生成所有代码并覆盖写入。只有--clean模式才会额外做删除旧文件的逻辑记录上一次生成的 manifest一个 JSON 文件列出所有由 t3code 创建的文件路径跟当前生成的文件列表做差集把差集中的文件删除。这个 manifest 文件存放在项目的.t3code/cache目录下。除非常规删模型需求高发的早期开发阶段日常使用我反而不建议开--clean因为它偶尔会误删手工保留的文件。4.5 字段描述里加了冒号导致 YAML 解析失败YAML 的冒号问题可以说是每个 YAML 用户的必修坑。字段描述为状态订单状态时如果没有在值两侧加引号yaml 解析器会认为是键值对嵌套而不是字符串。我初期用的时候也被坑过几次后来在描述文件里写任何包含中文标点的字符串都会自动加引号。这不是 t3code 的 bug但我在文档里明确建议描述和默认值统一加引号避免 YAML 的隐式转换。另外日期格式的值在 YAML 里也容易被自动解析成 Date 对象而不是字符串。比如字段默认值写2024-01-01解析出来会被当成 Date 实例生成代码时输出的是Mon Jan 01 2024 00:00:00 GMT0800。所以描述文件里的日期值务必写成字符串形式2024-01-01或者使用自定义的!date标签。t3code 在解析层对已知日期字段做了规避但如果你在枚举里写了日期字符串这个坑还是可能踩到。5. 项目后续扩展与个人实践心得5.1 模板扩展从类型生成到表单代码生成t3code 的另一个应用方向是表单代码生成。基于描述文件中的字段约束required、min、max、enum、format可以生成一套完整的配置化表单。每个字段生成对应的表单项配置输入框、下拉框、日期选择器、数字输入框以及配套的校验规则。具体来说字段类型为 enum 的生成Select组件配置options 来自枚举值字段类型为 email 的生成Input组件配置并带上 email 格式校验函数字段类型为 number 且带 min/max 的生成InputNumber规则里加上 min 和 max 校验。这套能力明显提高了后台管理系统的开发效率尤其是在列表页 新增页 编辑页这种重复度特别高的场景下。目前表单生成还处于生成配置对象而不是生成整个页面的阶段。生成整个页面太容易产出一堆不可维护的代码反而违背了 t3code 的初衷。我更推荐的做法是生成一套标准化的表单配置模块由项目内的通用表单渲染组件来消费。这样既享受了自动生成的效率又保持了业务代码的可维护边界。5.2 描述文件维护的最佳实践使用 t3code 一年多我总结了一条核心经验描述文件的质量决定了生成代码的质量千万不要把描述文件当成流水账。它和接口文档一样需要版本管理、需要评审、需要注释。我推荐一个工作流后端开始设计 API 时就同步更新models/model.yaml后端接口评审时把描述文件作为评审材料之一。如果描述文件里某个字段的注释写得不清楚生成代码里的 JSDoc 自然也是模糊的联调时的沟通成本依然在。所以写描述文件时应该把字段注释写成给接手者看的话而不是为了格式凑数。另外我也建议每个项目组约定一次生成、两天内提交的节奏。不要连续多天修改描述文件而不提交生成结果否则 git 历史里的师父关系和 t3code 的生成结果很难对应排查问题的效率大打折扣。5.3 关于代码生成工具应该生成多少代码的思考聊到最后我想说说我对代码生成工具边界的一个核心体会。t3code 的生成范围是类型定义、API 请求函数、Mock 数据和表单配置。不包括 UI 组件、路由、状态管理等。为什么停在这里因为代码生成有一个隐含的成本生成即绑定。一旦某些代码是生成的你就不能轻易手工修改它否则下一次生成会把你的改动冲掉。所以我的原则很简单只生成人不太会想去手工调整的部分也就是类型定义和接口请求层。这部分代码结构稳定、逻辑重复率高手工写是纯粹的体力活。而 UI 组件、页面编排、业务状态这些高变动性的内容就应该保留为手写代码。如果真的想让 t3code 扩展到 UI 层也应该是生成配置数据而不是生成完整组件代码把可变的视觉细节留给渲染组件去处理。t3code 不是要替代开发者写代码而是把写类型定义和写接口函数这两件枯燥的事情自动化让人把精力转移到真正需要创造力的事情上去。我个人在这套工具上最大的收获其实不是省了多少时间而是联调时候的那些为什么跟你给我的字段对不上这里少返回了一个字段的低效沟通彻底被堵住了。这条路线我觉得是值得继续走深的后面我还打算在 schema 驱动的 directions上再扩展一些场景比如自动生成 OpenAPI 文档、自动生成数据库迁移脚本把整个从数据到代码的链路都打通。