1. 从一个名字开始的“t3code”:它到底解决什么问题?
第一次看到“t3code”这个名字,我第一反应是“TypeScript Third Type”?还是“Type-to-Code”?后来真上手折腾了一遍才明白,这其实是一个典型的“类型驱动代码生成”工具。名字里的 t3,可以理解成 “Type、Template、Target” 三者的缩写,也可以粗暴理解为 TypeScript + Type-generator + Target-code,反正它的核心就一件事:你定义好类型和规则,它帮你把重复的模板代码批量吐出来。
先说说这东西出现的背景。做过中大型前端项目或者全栈项目的朋友应该深有体会:一个后端 API 出来了,前端要写 request 函数、要写接口的类型定义、要写表单校验规则、要写 mock 数据;后端这边呢,又要写 DTO、写实体类、写路由的基本骨架。这些代码 90% 是套路化的,改的也就是字段名和类型而已。手工写不仅枯燥,还容易因为字段漏写或者类型不一致,在联调的时候踩坑。
t3code 这类工具就是为了把这一层“重复劳动”抽出来,变成可配置、可复用、可团队共享的自动化流程。它的核心价值不在于会写多少种语言的代码,而在于它能让你把类型定义作为唯一的真相源,由它来驱动多个端、多种文件的生成。这在工程化水平比较高的团队里,价值是实打实的。
这篇文章我会从实战角度,把这个工具的设计思路、核心配置、实操流程、常见坑位讲透。适合正在做前后端分离项目、被联调和重复代码折磨过的开发者,也适合想了解“代码生成器到底怎么设计”的同学。我不讲花架子,就讲我自己跑通一个完整流程之后总结出来的东西。
2. 核心设计思路:为什么要把“类型”当成唯一可信源
2.1 传统的脚手架方案输在哪?
在 t3code 出现之前,我们团队用过不少代码生成方案,比如各个框架官方的 CLI 脚手架。脚手架的好处是初始化快,一条命令拉下来一个能跑的项目模板。但它的致命弱点是一次性:项目跑起来之后,脚手架就不再参与后续开发了。后端新增一个字段,你得手动去改前端的接口定义文件、改校验规则、改表格列配置;如果忘了改某一处,运行时不报错,但数据就是不对,排查起来特别费劲。
还有一类方案是 Swagger/OpenAPI 代码生成。这个思路其实非常接近 t3code,都是基于一份接口描述文件生成客户端代码。但 Swagger 的问题是描述文件的内容和代码是分离的:你在代码里加了字段,要记得同步去更新注解或者 YAML 文件,同步一断,生成的东西就开始失真。而且 Swagger 生成出来的客户端代码通常偏笨重,很多团队最后只取其中的类型定义部分,其余仍然是自己写。
t3code 的切入点不太一样:它把类型定义本身作为输入。你可以用 TypeScript 接口、可以用 JSON Schema、也可以用数据库表结构描述文件,反正都是结构化描述。然后它根据这些定义,通过模板渲染引擎,生成你想要的任何目标代码。这里面多了一个关键的中间层——“模板”。模板是你可以完全控制的。这意味着它不像 Scaffold 那样只能生成固定套路,也不像 OpenAPI 生成器那样受限于某个供应商的实现风格。你想要 Trpc 风格的 router 代码,想要 React Query 的 hooks,想要 DTO 类的 Java 代码,都可以通过换模板实现。
2.2 t3code 的三层架构:定义层、渲染层、输出层
把 t3code 拆开看,其实就三个层次。
第一层是定义层。这一层解决的是“信息从哪里来”。我实测下来,目前最顺手的输入方式是写 TypeScript 接口,因为团队里大部分人都熟 TypeScript 语法,而且它天然支持泛型、联合类型和注释。比如你定义一个User接口,写清楚字段名、字段类型、可选还是必选,再通过 JSDoc 注释补充说明(例如“这个字段在创建时不用传”),t3code 就能充分理解这个类型的语义。
第二层是渲染层。这一层是核心引擎。它读取定义层解析出来的“中间表示”——可以理解成一张结构化的元数据表,里面记录了每个类型的字段名、类型、校验规则、注释信息等。然后它把这些数据塞进模板文件里。模板是一种类似 EJS 或 Handlebars 的语法文件。你可以控制循环的写法、条件判断的逻辑、文件名的生成规则。这一层的灵活程度决定了工具的上限。
第三层是输出层。这就是最后生成出来的一堆文件。可以输出到前端项目的src/api目录,也可以输出到后端项目的src/dto目录,还可以同时输出到两个位置。它不关心你项目用的什么框架,只要你的模板写的是 React、Vue、Express、NestJS 都能对应上即可。
这种三层拆分的架构,最大的好处是关注点分离。定义层做语义描述,渲染层做代码风格控制,输出层做工程落地。三层之间的依赖关系是单向的、清晰的。这也是为什么 t3code 能被应用到不同技术栈项目里的原因。
2.3 使用前必须先想清楚的“约定”
再好的工具也怕乱用。t3code 虽然灵活,但它有几个关键约定你必须提前和团队对齐,否则生成出来的代码会出现各种穿帮。
第一,类型命名规则。t3code 默认会根据类型名称和文件名来判断生成逻辑。比如IUser、UserDto、UserEntity这些名字,虽然核心字段一样,但生成的代码风格会不同。建议团队统一采用语义化命名,比如UserProfile表示用户资料、UserCreatePayload表示创建用户的请求体,避免出现DTO、VO、PO混着用的混乱局面。
第二,字段注释规范。t3code 生成代码时,会尽量把注释也带过去。但注释写在字段后面和写在字段上方,解析结果是不同的。我只能说,根据我的实际体验,JSDoc 风格的三斜杠注释兼容性最好,别用//行尾注释或者靠后置字段标签。
第三,输出目录必须固定。t3code 支持你配置多个输出目标,但每个目标都建议使用独立目录。因为它的生成策略默认是“先清空目标目录,再全量写入”。如果你不小心把输出目录配置成了src,一次生成可能把覆盖掉你手工维护的文件。这个坑我踩过,后面会详细讲。
3. 核心功能拆解与实操要点
3.1 配置文件的规范与写法
t3code 的配置文件支持t3code.config.ts或t3code.config.json。我比较推荐 TypeScript 格式的配置文件,因为可以做类型检查,写起来有提示,不容易出错。
一个典型的最小配置长这样:
import { defineConfig } from 't3code'; export default defineConfig({ input: { // 类型定义文件的路径,支持 glob 表达式 include: ['src/**/*.d.ts', 'src/shared/**/*.ts'], exclude: ['src/**/*.spec.ts'], }, generate: [ { templateDir: 'templates/api-client', outputDir: '../frontend/src/api/generated', // 生成文件的前缀 filePrefix: '', // 是否在生成前清空输出目录 clean: true, }, { templateDir: 'templates/dto-class', outputDir: '../backend/src/dtos/generated', clean: true, }, ], });这里面每一项都有讲究。
include里的路径,建议只指向专门存放类型定义的目录,比如types或者shared。不要偷懒直接配成src/**/*.ts,因为这样会把业务逻辑代码里的类型也扫进来,解析时不仅慢,还可能因为一些复杂类型(如函数重载、条件类型)导致报错。
generate数组里的每一项对应一个生成目标。每个目标都有独立的templateDir和outputDir,这一点设计得很好,因为你可以让同一个输入定义分别生成不同风格的代码。比如前端项目要生成轻量的 fetch 封装,后端项目要生成带参数校验的 DTO 类。
3.2 模板引擎语法与应用套路
t3code 的模板语法我猜你上手十分钟就能习惯,因为它就是典型的“HTML 里嵌逻辑”的写法。下面是一个最简单的“根据类型生成 TypeScript 接口”的模板示例:
<% for (const entity of entities) { %> // <%= entity.description %> export interface <%= entity.name %> { <% for (const field of entity.fields) { %> /** * <%= field.description || '' %> * 生成时间: <%= new Date().toISOString() %> */ <%= field.name %><%= field.required ? '' : '?' %>: <%= field.tsType %>; <% } %> } <% } %>别看这段简单,它已经把“循环”和“插值”两个核心功能体现出来了。实际使用中,我的经验是模板文件不要超过几十行,一旦超过,维护成本会急剧上升。你更应该把模板拆成片段文件,通过 include 的方式组合。
<% include('partials/imports.ejs') %> // ...核心生成逻辑常见的模板套路我总结了几种:
套路一:全量覆盖式。适合生成 API 客户端、常量定义、枚举映射表这类“纯机器产物”。每次生成都把循环里的所有类型全部输出到同一个文件,简单粗暴。
套路二:按实体拆分文件。每个实体生成一个文件,文件名用实体名。这种适合生成后端 DTO、数据库实体类。t3code 里可以用outFile属性来控制。
套路三:条件包含式。通过模板里的条件判断,让一个类型的不同变体输出不同内容。比如UserCreatePayload不会包含id字段,UserUpdatePayload会把所有字段置为可选,这些完全可以通过字段元数据上的修饰符来控制。
我强烈建议你在团队里建立一个小型“模板库维护组”,哪怕只是一个人兼职负责。模板这种东西,属于典型的“写起来容易维护起来难”,一旦格式风格确立,最好不要频繁改动,否则生成出来的代码满天飞,风格不统一反而更乱。
3.3 字段类型映射与校验规则的高级配置
类型映射是 t3code 里最核心的机制。它解决的是“TS 类型和 Java 类型怎么对应”的问题。举个例子:你在 TS 里写email: string,生成 Java DTO 时,t3code 需要知道它应该变成private String email。这个映射关系是可配置的。
export default defineConfig({ typeMappings: { // key 是源类型,value 是目标类型 'string': 'String', 'number': 'Integer', 'boolean': 'Boolean', 'string[]': 'List<String>', 'Date': 'LocalDateTime', 'Record<string, any>': 'Map<String, Object>', }, });不同目标的代码生成器可以配置不同的映射表。前端项目的typeMappings比较简单,基本一对一;后端项目需要花点心思,因为 Java 里没有直接对应的Record<string, any>类型,你还需要决定它在不同场景下是转成Map还是自定义一个类。
我还发现 t3code 支持一种“扩展标记”的写法,可以在类型注释里指定字段的 JSON Schema 约束,比如@minLength(2)、@maximum(100)。这些标记会被解析进中间表示,模板里就能读取到并进行输出。这么说可能有点抽象,我举个具体例子:
/** * 用户名 * @minLength 2 * @maxLength 20 * @pattern ^[a-zA-Z0-9_]+$ */ username: string;如果模板里写了校验逻辑,那么生成出来的代码可能是:
export const usernameRules = { minLength: 2, maxLength: 20, pattern: /^[a-zA-Z0-9_]+$/, };这样做的好处非常多:校验逻辑不再散落在前端表单、后端 DTO 多个地方,而是统一由类型定义驱动。只要源头改了,重新跑一次生成,前后端就同步更新了。检查一下 TypeScript 泛型、联合类型这类比较复杂的定义在实际生成中能不能完美解析。这里面还有一个细节值得注意:如果你在注释里写了@minLength 2和@minLength 10重复的标记,t3code 会取最后一个还是报错?我实测的结果是它能跑,但只取第一个合法的,后面的忽略。这也提醒了我:注释标记虽好,但写多了容易乱,建议团队约定只保留核心约束。
3.4 输出文件的后处理:格式化与 lint 集成
生成出来的代码难免风格各异,直接扔进项目里肯定会影响 lint 检查。我的经验是:t3code 本身仅供生成,建议把格式化这步交给项目已有的工具链。比如前端项目生成完之后跑一遍prettier和eslint --fix;后端项目跑一下spotlessApply或类似工具。
在实际执行中,可以在配置文件里加一个afterGenerateHooks配置,类似这种:
export default defineConfig({ hooks: { afterGenerate: [ 'prettier --write generated/**/*.{ts,tsx}', 'eslint --fix generated/**/*.ts', ], }, });配置了钩子之后,每次生成完代码,自动执行格式化,非常省事。不过有一点必须注意:钩子命令超时要设置超时时间,默认 10 秒就可能不够,如果项目大、文件多,建议调到 30 秒或者 60 秒。还有一个细节,钩子命令执行的工作目录是项目根目录,如果你的t3code.config.ts存在于子目录里,注意路径写法要带相对路径前缀。
4. 实操过程:一个团队从零到一跑通 Type-to-HTTP-Client
4.1 准备阶段:定义好你的第一个类型文件
我以一个贴近真实业务的例子来走一遍。假设我们正在做一个小型电商后台,第一个模块是“商品管理”。我不会先写前端页面,而是先定义一个商品相关的类型文件。
// src/shared/types/product.ts /** * 商品基础信息 */ export interface Product { /** * 商品 ID */ id: string; /** * 商品名称 * @minLength 1 * @maxLength 100 */ name: string; /** * 商品价格,单位:分 * @minimum 0 */ price: number; /** * 商品状态 */ status: ProductStatus; /** * 商品标签 */ tags: string[]; /** * 商品起售时间 */ saleStartTime: Date; /** * 是否上架 */ isActive: boolean; /** * 关联分类 ID 列表 */ categoryIds: string[]; } /** * 商品状态枚举 */ export type ProductStatus = 'draft' | 'onSale' | 'offSale' | 'deleted';这个文件我特意设计了几个有代表性的类型:基础类型、枚举联合类型、数组、时间类型、可空字段等。实际项目中你肯定还会遇到更多复杂类型,比如泛型接口、嵌套对象、接口继承等,但先把这一套跑通,后面的套路就清楚了。
4.2 编写第一个模板文件
有了类型定义,接下来就是写模板。我先从一个最常用的场景入手:为前端生成一套类型安全、带自动导入的请求函数。
先看目录结构:
templates/ http-client/ request.ejs index.ejs types.ejstypes.ejs用于生成所有类型定义的 TS 类型声明,内容其实很简单,就是把中间表示还原成 TS 接口:
<% for (const entity of entities) { %> // <%= entity.description %> export interface <%= entity.name %> { <% for (const field of entity.fields) { %> <%= field.name %><%= field.required ? '' : '?' %>: <%= field.tsType %>; <% } %> } <% } %> // 枚举类联合类型 <% for (const union of typeAliases) { %> export type <%= union.name %> = <%= union.members.map(m => `'${m}'`).join(' | ') %>; <% } %>实际生成的效果是:
// 商品基础信息 export interface Product { id: string; name: string; // ...省略 }request.ejs是请求函数模板:
import { http } from '../http'; import type { <%= entity.name %> } from './types'; export function getProduct(id: string) { return http.get<<%= entity.name %>>(`/product/${id}`); }这里的http是你项目里已有的请求封装,t3code 只是帮你组装方法签名和路径。
4.3 执行生成与集成到项目
配置好之后,执行命令:
npx t3code generate跑完之后你会看到类似这样的输出:
[生成成功] 模板: http-client - 文件 ./types.ts - 文件 ./product.ts - 文件 ./index.ts - 耗时 32ms进入前端项目的src/api/generated目录,看到多了三个文件,而且已经被 prettier 格式化过了。手写代码的场景,我估算要花 15~20 分钟;t3code 只需要不到 1 分钟,而且这 1 分钟里还包括了我手滑打错一个路径的时间。
4.4 对比收益:值不值得引入团队
光说生成快,可能还不够直观。我把这个工具在我们团队里跑了一个月之后的数据和感受大致列了一下:
收益明显的方面:
- 接口联调时,字段对不上的情况明显减少。因为前后端的类型都从同一份类型定义生成,字段名和类型天然一致,不会再出现“后端改了个字段名,前端不知道”的尴尬。
- 新成员上手成本降低。新人不需要去读一坨接口文档,直接看到生成的类型文件和函数签名,就知道该传什么参数、返回什么结构。
- 后端 DTO 的维护从手工变成半自动。每次数据库表结构变更后,重新生成一下,代码自动同步,省去了大量机械修改。
需要注意的方面:
- 刚接入的一两周会增加一些“额外负担”。现有类型定义不全、模板风格需要调整,这些都是迁移成本。
- 过度依赖生成代码也有风险。如果团队里没人关心生成出来的代码质量,那么模板写得差,生成出来的东西也差,反而比手写更乱。
我的结论是:t3code 适合中大型项目、团队类型定义纪律比较好的场景。如果项目本身就没人维护类型定义,那你得先解决“类型定义规范”的问题,再来谈代码生成。
5. 常见问题与排查技巧实录
5.1 生成内容为空或缺少文件
这个问题我初期遇到好几次,排查思路非常有规律。t3code 生成的内容为空,基本都是输入解析阶段出了问题。
第一检查include的 glob 表达式是否正确。很多人会把src/**/*.d.ts误写成src/**/*.ts,导致types目录下的定义文件没被扫到。
第二检查定义文件是否使用了export关键字。没有导出的类型,在解析阶段会被当成普通模块内部类型,直接跳过。这个问题很隐蔽,因为你在编辑器里看着这个类型很正常,但生成时就是没它。
第三查看详细日志。t3code 支持--verbose参数,跑一次可以看到每个输入文件的解析状态。日志里如果出现WARN: Skipping type ...,基本就能定位到是上面的哪个原因。
5.2 类型的字段丢失或生成后格式错乱
这是模板编写阶段最常见的坑。
字段丢失往往是因为模板里的遍历变量名写错了。比如我在中间表示里用的字段变量是field.name,模板里顺手写成item.name,生成的时候就是个空字符串,也不会报错,静默失败。
格式错乱通常是模板的缩进问题。EJS 风格的模板会保留原样的空白符,如果模板里缩进写了两个空格,生成出来的代码可能带了脏的空格前缀。建议统一使用trim选项,或者在生成后强制格式化。
另外我要特别提醒一件事:不要在模板里写复杂的递归逻辑。比如你要根据一个嵌套的对象类型递归生成对应的 interface,模板引擎实际上能处理,但写出来可读性极差,一旦出错根本不好排查。我后来都是把这类复杂逻辑抽出来,在“预处理器”里先把嵌套结构拍平,生成一个扁平化的中间表示,模板只做简单的循环输出。
5.3 覆盖风险:输出目录配置不可大意
前面提到过clean: true会在生成前清空输出目录。一旦你把outputDir配错,比如配成了项目的src根目录,或者配成了另一个团队在用的公共目录,损失将会很惨重。我团队里就出现过一次:同事把outputDir配成了shared目录,然后把里面手工维护的一些公共组件给清了,还好有 git 支撑,最后回滚了,但中间吓出了一身冷汗。
我后来定了一条制度:输出目录必须是独立、可再生成的目录,严禁混入手工维护的文件。并且git status里如果发现每次生成后都有一堆手动修改过的文件被删除,就需要立即停下来检查配置。如果你还想要一层保险,可以对 outputDir 加一个“校验前缀”,配置一个allowedOutputRoots,只允许输出到指定的几个目录下,其他一律报错。
5.4 生成速度慢和内存占用大的处理
中大型项目里,类型定义文件可能有几百上千个,生成时 t3code 需要一次性解析所有文件,构建完整的中间表示。如果解析阶段比较久,可以通过缩小include范围来优化。
举个例子,把原来一次性扫描src/**/*.ts改成按模块拆分,分别配置不同的生成目标。比如product模块只扫描src/product/types/**,order模块只扫src/order/types/**。这样不仅生成快,代码之间的关系也更清楚。
6. 避坑清单与进阶建议
6.1 小型团队上手的三个建议
如果你看完前面的内容准备对接这个工具,我建议你按顺序把这三件事做了。
第一,先做“类型定义大扫除”。把项目中已有的手工类型梳理一遍,产出标准化的类型文件,为后续生成打好基础。这一步最枯燥,但收益也最大。
第二,挑一个模块先试点。不要一上来就全局接入,先拿一个业务模块跑通流程,把模板打磨好,再逐步推广到其他模块。
第三,把模板和配置纳入 Code Review 范围。模板不是一次性产物,它是后面所有生成代码的“生产工艺”。生产工具出问题,生成的代码全是错。 我见过团队把模板提交后基本没人再碰,后来业务一直在变,模板里面的逻辑早就不适应了,每次运行 v3code 生成的代码都得手动修,反而比手写还累。定期维护模板,真的很重要。
6.2 能跑起来之后,可以再研究什么
t3code 的生成功能跑通之后,其实还可以往“自动化流程”方向延伸。比如结合 Git hooks,在每次提交后端接口变更时自动重新生成前端代码并提交,减少人工触发。也可以跟 CI/CD 结合,在后端服务发布前自动比较生成代码和最新类型定义是否一致,不一致就阻止发布。
这些扩展思路听起来很美好,但落地时一定会有各种细节问题。比如 Git hook 里生成代码需要确定用户 profile 环境,CI 里需要跨仓库拉取模板等。我的建议是先手动跑通,再尝试半自动,最后才上全自动。步子别迈太大,否则出了问题都不知道是哪个环节。
6.3 一份可以直接参考的模板目录结构
最后我把我们团队目前用着比较顺的模板目录结构分享出来,你可以直接抄作业:
templates/ shared/ page-list.ejs form-elements.ejs api-client/ index.ejs request.ejs types.ejs react-query/ index.ejs hook-factory.ejs backend-dto/ class.ejs validation.ejs t3code.config.tsshared目录里放一些被多个模板引用的公共片段,比如分页参数类型、时间格式处理等。这样不同生成目标之间还能复用一部分逻辑,避免相同的处理逻辑写在多个模板里,改一处要改三个地方。
算下来,这套东西在团队里跑了小半年,最大的变化是前端和后端的联调效率确实上来了,重复性代码带来的“手滑类 bug”也少了很多。当然,它不能替代你思考业务设计,也不可能写出高超的算法逻辑。它就是一个很称手的“复印机”,你给它源材料,它给你产好看的成品。但前提是,你得先有那份清晰准确的源材料。类型定义就是这份源材料,值得咱们多花点心思打磨。