如果只是偶尔写一次接口对接,我不会专门写 t3code 这个工具。但当你连续五六个需求都在做同一件事——对着接口文档把字段一个个敲成 TypeScript 类型,抄一遍 URL 再包一层请求函数,然后回到组件里写 loading、错误处理、重新拉取——你也会想找一个能一键完成这些重复步骤的东西。t3code 就是我们团队花了三个月时间打磨出来的答案。
t3code 读作 T-three-code,含义是 TypeScript、Type-Safe、Template,核心功能非常直接:喂一段接口描述,自动生成类型定义、API 封装函数和 React Hooks 三份代码。这篇文章会把它的诞生背景、设计思路、核心实现和踩过的坑完整过一遍,适合正在维护中后台项目、天天跟各种 REST 接口打交道的人,也适合在做前端基建、写脚手架工具的同学参考。
1. 被重复的接口对接逼出来的 t3code:先从团队里的脏活说起
1.1 T3 这个前缀从哪里来
我们团队的技术栈是社区里常说的 T3 Stack:Next.js 做框架、tRPC 做接口、Tailwind 写样式、TypeScript 收底。这个组合最大的卖点是全链路类型安全——从数据库查询到后端路由,再到前端组件的useQuery,类型理论上能一路贯通,少了大量手工维护心累的时刻。
但实际用下来会发现一个很扎心的事实:全链路类型安全只覆盖了“自家后端起的新接口”。真实项目里总有大量历史 REST 接口、第三方开放平台接口、甚至只有一份 Markdown 文档的老服务,它们没有 OpenAPI 文件,也没有 tRPC 路由,对接时一切回到原始社会——手写类型、手拼 URL、手写 loading 逻辑。t3code 这个名字就是在这个背景下定下来的,我们想补上 T3 理念里缺失的那一环:让任意接口描述也能变成类型安全的代码。
为什么叫 t3code 而不叫别的名字?因为它在团队里的定位就是“T3 Stack 的代码补全工具”,处理的不是新接口,而是那些没人管的旧接口、外部接口。名字带 T3,团队成员一看就知道这东西属于整个技术体系的一部分。
1.2 三个让我烦到动手写工具的真实场景
第一个场景是字段重命名。后端接口喜欢返回snake_case,比如order_status、created_at、total_amount,而前端代码习惯用camelCase。每次对接新接口,都要把几十个字段驼峰化,复制到 TypeScript 类型里,还要注意别漏掉某个字段。手敲一次没问题,敲十次就不是体力活而是纯粹的折磨了。
第二个场景是请求封装的复制粘贴。一个标准 REST 接口至少需要:拼接 URL、处理 query 参数、设置超时和错误处理、解析 JSON、抛出业务异常。这些代码在项目里每个接口都要来一遍,去掉参数不同之外,结构几乎完全一样,但你就是得一遍遍复制粘贴,还得反复修 bug。
第三个场景是 React Hooks 的重复劳动。类型有了、请求函数有了,页面上还得写useState管理数据、useEffect触发请求、loading和error状态同步。刚开始写还挺新鲜,写到后面我都条件反射了,手指比脑子先动。这三件事叠加在一起,缺的已经不是一个工具,而是一个能把它们串起来一锅端的东西。
1.3 为什么没直接选现成的 openapi-generator 或 orval
说实话,最早我也不是没考虑过现成方案。openapi-generator 是老牌工具,orval 在 React 生态里口碑也不错。但评估一圈下来,发现都不太贴合我们的处境:
| 方案 | 优点 | 在团队场景里的问题 |
|---|---|---|
| openapi-generator | 支持语言多、功能全 | 依赖完整 OpenAPI 文档,内部老接口根本不具备 |
| orval | React 集成好,支持 hooks 生成 | 同样强依赖 OpenAPI 规范,定制模板要学它的 DSL |
| 手写脚本 | 灵活 | 没有统一抽象,每人写一套,维护成本高 |
我们的现实是:很多老服务的接口文档就是一份手写的 Markdown,甚至只有代码注释。连 OpenAPI 规范都没有的东西,不可能丢给 openapi-generator 去解析。与其迁就工具去补文档,不如做一个只依赖“简单接口描述”的轻量工具,描述文件长什么样我们自己定,生成逻辑简单到每个成员都能看懂、能改,这才是 t3code 选择自定义 schema 的最根本原因。
2. t3code 只做一件事:把接口描述变成三段式代码
2.1 输入输出设计:一段描述换三份代码
t3code 的输入不是 OpenAPI 文件,而是一份极度简化的 JSON schema。我一直觉得,工具的第一版最怕给自己加戏,能少定义一个概念就少定义一个。所以 t3code 的 schema 只保留了五个要素:类型名、字段名、字段类型、是否必填、枚举值。下面这个示例基本就是全部语法了:
{ "name": "Order", "fields": [ { "name": "id", "type": "string", "required": true }, { "name": "amount", "type": "number", "required": true }, { "name": "status", "type": "enum", "enumValues": ["pending", "paid", "cancelled"], "required": true }, { "name": "items", "type": "array", "itemType": "OrderItem", "required": true }, { "name": "remark", "type": "string", "required": false } ] }有嵌套对象?直接写一个itemType指向另一个类型名,递归解析。有列表接口?单独定义一个OrderListQuery和OrderListResult即可。没有泛型系统、没有继承关系、没有组合规则,能用简单结构表达的,绝不上复杂概念。文件里承载了哪些业务之后,没覆盖那些。靠一句话很难说服人,这里我展开讲讲。
输出是固定结构的目录:
src/generated/ types.ts api.ts hooks.ts index.tstypes.ts放所有类型和枚举,api.ts放请求函数,hooks.ts放 React Hooks,index.ts统一导出。一次生成,三个文件,各司其职。
2.2 为什么固定分成三段而不是一个大文件
最早的原型版本其实是单文件输出,所有类型、函数、hooks 全塞进一个api.ts。用了两个礼拜就后悔了,原因有三条:
首先,代码耦合太紧。页面上只要能用到某个 hooks 的修正,结果是十几行的改动。其次,类型和请求混在一起,类型文件约等于没用。第三,三个文件的更新频率完全不同:类型文件只在接口定义变化时重新生成,api 文件在接口变动时需要重新生成,hooks 文件则经常需要手工加逻辑,比如加了重试、本地缓存、请求竞态处理。如果都放一起,每次生成都是整个文件覆盖,手工代码必然被冲掉,这是最要命的。
所以后来设计定型为三段式:types.ts 只做类型声明,完全没有运行时代码;api.ts 只做数据请求,不涉及 UI 状态;hooks.ts 是唯一允许手工扩展的文件,通过保留区块机制跟自动生成内容共存。这个划分既是技术划分,也是职责划分——谁改什么文件一目了然。
2.3 t3code 和 tRPC 的边界在哪里
“已经用 tRPC 了,为什么还需要这个工具?”这是团队里最常被问到的问题。我每次都会解释一遍:tRPC 的 router 输出天然带类型,前端通过typeof router就能拿到完整推断,这部分确实不需要 t3code 插手。但现实中的项目不会是 100% tRPC,总有一些接口因为历史原因、第三方原因没法迁移。比如对接微信支付回调、对接某个外部天气服务、访问老 PHP 系统留下的 REST API,这些接口没有 tRPC 路由,类型安全就只能靠自己。
t3code 的设计原则很明确:不跟 tRPC 抢活,只做 tRPC 覆盖不到的脏活。描述文件里也专门加了一个source字段,标记这个接口是tRPC还是REST,如果标注为tRPC,生成器会直接跳过并提示“该接口由 tRPC 自动推断,无需生成”。这个设计出来之后,工具在团队里的接受度明显变高——大家不用担心生成代码和现有架构冲突。
3. 实现 t3code 的核心流程:CLI、模板引擎与类型解析
3.1 整体流程与工程结构
选定方案后,实现本身反而是最顺畅的部分。t3code 跑在 Node.js 上,采用标准的 CLI + 模板引擎 + 文件写入三段流水线。整个流程用文字描述就是:解析命令行参数 → 读取 JSON schema 文件 → 解析出类型、枚举、接口列表 → 渲染三个模板 → 写入内存中的文件内容 → 用 Prettier 格式化 → 写盘。流程图就不画了,工程结构比图更直观:
t3code/ package.json tsconfig.json bin/ t3code.mjs // CLI 入口 src/ index.ts // 主流程编排 parseConfig.ts // 读取和校验 schema generateTypes.ts // 类型生成器 generateApi.ts // API 函数生成器 generateHooks.ts // Hooks 生成器 naming.ts // 命名转换工具 templates/ types.hbs api.hbs hooks.hbs3.2 CLI 层:commander 参数设计与校验
CLI 层用了 commander 做参数解析,这是 Node 生态里最成熟的方案。t3code 的命令行设计遵循一个原则:必选参数尽量少,常用参数必须有默认值。最终收敛下来就三个参数:
import { Command } from 'commander'; const program = new Command(); program .name('t3code') .description('Generate types, api functions and hooks from schema') .requiredOption('-i, --input <path>', 'path to schema json file') .option('-o, --output <path>', 'output directory', './src/generated') .option('--no-format', 'skip prettier formatting') .option('--check', 'only show diff, do not write files'); program.parse();这几个参数对应的都是实际使用中真实的痛点。--input不用多解释,没输入文件什么都干不了;--output默认./src/generated是因为团队一半以上项目都这么组织目录;--no-format是给调试用的,有时候想看看模板原始的渲染输出,免去 Prettier 干扰;--check则是给 CI 用的,只比较差异不写文件,代码是否过期一眼就能看出来。
为什么不在 CLI 里塞更多配置项?因为配置项越多,用户的学习成本越高。我自己见过太多工具死于配置爆炸,所以 t3code 把 90% 的选项都收敛到 schema 文件里,命令行只保留最核心的入口。
3.3 模板引擎:为什么用 handlebars 而不是字符串拼接
生成代码的方式无非两种:模板渲染和字符串拼接。我一开始偷懒选了字符串拼接,因为觉得代码量不大,写起来更快。结果嵌套类型一多,拼接代码里全是+和换行符,眼睛都快看瞎了。后来老老实实换了 handlebars,世界瞬间清净。
模板文件长这样:
{{#each interfaces}} export interface {{name}} { {{#each fields}} {{indent}}{{name}}{{#unless required}}?{{/unless}}: {{{type}}}; {{/each}} } {{/each}}选择 handlebars 而不是 ejs 或者 nunjucks,理由很简单:handlebars 强制模板是“无逻辑”的,函数调用都在预生成的 context 里完成。这看起来是个限制,但实际上是个保护——它逼着你把命名转换、类型判断、缩进处理这些逻辑全部放在 TypeScript 代码里做好,模板只负责把结果铺出来。这样做的好处是模板文件不会堆满 if/else,任何团队成员打开都能看懂生成逻辑。
3.4 类型解析:递归遍历与命名转换
类型解析是 t3code 的核心难点。schema 里一个字段的类型可能是基础类型、枚举、嵌套对象、数组,而数组元素又可能是一个对象。解析时必须递归遍历,把分散的信息收集成统一的类型模型。
export function parseType(schema: Schema): ParsedType { const interfaces: InterfaceDef[] = []; const enums: EnumDef[] = []; const visited = new Set<string>(); function walk(current: Schema): void { if (visited.has(current.name)) return; visited.add(current.name); for (const field of current.fields) { if (field.type === 'enum') { enums.push({ name: toPascalCase(field.name) + 'Enum', values: field.enumValues ?? [], }); } if (field.type === 'object') { field.itemType = field.itemType ?? toPascalCase(field.name); // 递归解析嵌套对象 if (field.nestedSchema) walk(field.nestedSchema); } if (field.type === 'array' && field.itemType) { // 数组元素如果指向对象,需要检查该对象是否已在 schema 列表中 } } interfaces.push({ name: current.name, fields: current.fields.map((f) => { const t = mapFieldType(f); return { name: toCamelCase(f.name), required: f.required, type: t, }; }), }); } walk(schema); return { interfaces, enums }; }这里最需要注意的是visited集合。schema 就算写成纯树形结构,编程语言里也不排除有人写出循环引用——A 嵌套 B,B 嵌套 A。没有 visited 保护,生成进程会直接死循环。加上之后,最多只是第一次遇到时保留引用,第二次就跳过,表现非常稳定。
命名转换也是一门学问。接口返回order_id,前端类型就要orderId,类型名要用 PascalCaseOrder,枚举值还要考虑“全大写”“首字母大写”的问题。这块逻辑全收敛在naming.ts里,规则是团队投票定的:字段名一律 camelCase,类型名一律 PascalCase,枚举名用 PascalCase 加 Enum 后缀,枚举值保持 schema 原样。之后所有模板统一走这两个函数,杜绝了各种命名不统一的问题。
3.5 文件写入:先格式化再落盘
生成代码最容易被忽视的一环是格式化。模板渲染出来的代码往往是乱糟糟的,缩进混乱、引号不统一。t3code 最后一步会调用 Prettier 的 JavaScript API,把内容格式化之后再写盘:
import prettier from 'prettier'; async function formatCode(content: string): Promise<string> { return prettier.format(content, { parser: 'typescript', singleQuote: true, trailingComma: 'all', printWidth: 100, }); }这里有个细节经验:先格式化再写文件,而不是写完之后再让编辑器格式化。因为生成工具应该保证“落盘即达标”,不能让开发者打开文件之后还要手动格式化。另外写文件不能直接覆写。我的做法是先生成临时文件,写入成功后 rename 覆盖旧文件,这样即使进程中途崩溃也不会留下半个文件。
4. 用一个带嵌套结构的订单接口,实测整套生成链路
4.1 一个贴近真实业务的 schema 长什么样
理论讲完,接一个实际案例。假设我们要对接一个订单详情接口,后端返回的数据结构包括订单基本信息、订单项列表、收件人信息,还有状态枚举。schema 写成这样:
[ { "name": "OrderStatus", "fields": [], "enumValues": ["pending", "paid", "cancelled", "delivered"] }, { "name": "Receiver", "fields": [ { "name": "name", "type": "string", "required": true }, { "name": "phone", "type": "string", "required": true }, { "name": "address", "type": "string", "required": true } ] }, { "name": "OrderItem", "fields": [ { "name": "sku_id", "type": "string", "required": true }, { "name": "name", "type": "string", "required": true }, { "name": "price", "type": "number", "required": true }, { "name": "quantity", "type": "number", "required": true } ] }, { "name": "Order", "fields": [ { "name": "id", "type": "string", "required": true }, { "name": "status", "type": "OrderStatus", "required": true }, { "name": "amount", "type": "number", "required": true }, { "name": "items", "type": "array", "itemType": "OrderItem", "required": true }, { "name": "receiver", "type": "Receiver", "required": false }, { "name": "remark", "type": "string", "required": false } ] } ]注意这个 schema 里我把OrderStatus也定义成了对象结构,但在解析时通过类型名识别出它是枚举。这个设计有点取巧,后面具体我会说。另外receiver是可选的,生成类型时要自动加?,而且嵌套对象 Receiver 本身还要独立生成 interface。
4.2 三段输出到底长什么样
执行npx t3code -i order.schema.json -o src/generated之后,types.ts文件内容如下:
export type OrderStatus = 'pending' | 'paid' | 'cancelled' | 'delivered'; export interface Receiver { name: string; phone: string; address: string; } export interface OrderItem { skuId: string; name: string; price: number; quantity: number; } export interface Order { id: string; status: OrderStatus; amount: number; items: OrderItem[]; receiver?: Receiver; remark?: string; }api.ts则是对应的请求封装:
export interface OrderDetailResult { code: number; data: Order; message: string; } export async function fetchOrderDetail(orderId: string): Promise<Order> { const res = await fetch(`/api/order/${orderId}`, { headers: { 'Content-Type': 'application/json' }, }); if (!res.ok) { throw new Error(`fetchOrderDetail failed: HTTP ${res.status}`); } const json: OrderDetailResult = await res.json(); if (json.code !== 0) { throw new Error(json.message || 'request failed'); } return json.data; }hooks.ts生成的是最基础的加载逻辑:
export function useOrderDetail(orderId: string) { const [data, setData] = useState<Order | null>(null); const [loading, setLoading] = useState<boolean>(false); const [error, setError] = useState<Error | null>(null); const load = useCallback(async () => { setLoading(true); setError(null); try { const order = await fetchOrderDetail(orderId); setData(order); } catch (e) { setError(e instanceof Error ? e : new Error(String(e))); } finally { setLoading(false); } }, [orderId]); useEffect(() => { load(); }, [load]); return { data, loading, error, reload: load }; }4.3 编译检查和页面接入效果
生成结束之后,我习惯立刻跑一遍 TypeScript 编译检查:
npx tsc --noEmit这一步能发现所有类型层面的问题。实际测试中,这个 schema 生成的代码一次性通过编译,没有报错。把useOrderDetail接到页面组件里,调用const { data, loading, error, reload } = useOrderDetail(orderId),再配合data?.items.map(...)渲染列表,整个过程非常顺滑。以前这类页面起码要手工写 100 行类型和请求逻辑,现在只需要一份 schema,生成完代码质量还更稳定。
这中间遇到过一次比较典型的编译报错:receiver是可选的,但页面直接用了data.receiver.name,TypeScript 立刻报错“对象可能为 undefined”,这就体现出?标记的价值——它是类型安全最后一道防线。
4.4 生成代码在可读性上的取舍
很多人担心自动生成的代码可读性差。我实际的经验是:只要模板写得好,生成代码的可读性可以比手写的更高。t3code 做对了几件事:字段注释从 schema 的description字段带过来;命名统一走工具函数;每个文件头部生成一行注释说明“此文件由 t3code 自动生成,请勿手工修改”。这行注释特别重要,它直接告诉每个看到文件的人:改这里没用,下次生成会被覆盖。
hooks.ts 是唯一例外的文件,它的头部注释写的是“自动生成 + 手工扩展共存”,并且给出了保留区块的用法,这个下文会细说。
5. 三个月的真实踩坑:类型判断、格式逃逸与增量合并
5.1 可选字段和数组空值:最容易翻车的位置
第一版 t3code 生成的类型,在可选字段上遇到了一堆问题。问题集中在两种情况:普通字段可选和数组可选。
普通字段可选的目标很简单,receiver可选就要生成receiver?: Receiver。但早期的实现是只要required为false就无脑加?,结果碰到字段类型是array的情况,就变成items?: OrderItem[]。这在 TypeScript 里语义是“字段可能存在,也可能不存在”,但业务上真正想表达的往往是“字段一定存在,但值可能是空数组[]”。这两个语义完全不同,后端一般都会回"items": [],不会把字段整个删掉。之后版本里我加了一个配置:arrayOptional: 'field' | 'element',可以自行表达。
数组元素本身的空值问题更隐蔽。比如接口可能返回items: [null, { ... }],这种脏数据如果直接信任类型,运行时就会崩。t3code 默认不处理这种情况,但在文档里明确提示:接了不靠谱的老接口,生成完了记得自己做一层数据清洗。工具能解决类型一致性问题,解决不了后端数据质量。
5.2 枚举生成:为什么放弃 TS enum 改用字符串字面量联合
早期版本生成的枚举长这样:
export enum OrderStatus { Pending = 'pending', Paid = 'paid', Cancelled = 'cancelled', }TS enum 在编辑器提示、switch 场景下看起来很好用。但实际用了两周,发现两个痛点:第一,TS enum 在运行时是一个真实对象,编译产物会多出一截var OrderStatus;代码,而且import到组件里也会带来不必要的运行时依赖;第二,后端接口字段值是纯字符串'paid',前端很多时候就是拿这个字符串去比较,TS enum 反而多了一道“从 enum 到 string”的转换。
后来参考了 T3 Stack 社区的主流风格,改成字符串字面量联合类型:
export type OrderStatus = 'pending' | 'paid' | 'cancelled' | 'delivered';联合类型在编译后完全不存在,零运行时成本,编辑器提示也毫不逊色。这是一个典型的“看起来退步、实际上进步”的取舍。如果你在自建工具,我的建议是:默认字符串字面量联合类型,除非业务明确要求枚举做数字映射。
5.3 日期字符串保留 string 还是转 Date
订单接口里一定会出现created_at、paid_at、cancelled_at这类字段。最早我以为处理的方案很优雅:把名字里带at或date的字段自动转成Date类型。结果第一版上线就被打脸——后端返回的是"2025-06-01T12:00:00Z"字符串,前端如果要直接展示,用string类型反而更省事;转了Date之后,页面上还得自己格式化、处理时区,反而多一手。
最终方案是提供dateMode: 'string' | 'smartDate'两个选项,默认'string'。原因是保持“所见即所得”,接口返回什么,类型就写什么,什么时候需要 Date 由开发者在业务代码里手动转换。自动推断是一门玄学,在没有把握的情况下不要玩,宁可让开发者多写一行new Date(...),也不能让工具替你猜业务需求。
5.4 从全量覆盖到增量合并:hooks 文件不能每次被干掉
最早的 hooks.ts 生成策略是全量覆盖——每天改一次 schema,当天手动在 hooks 里加的缓存、重试、竞态处理全被清理掉。这个问题发生了两次之后,我们决定引入保留区块机制。具体做法是在模板里支持两个特殊注释:
export function useOrderDetail(orderId: string) { // t3code:start 手工代码区开始 const CACHE_TTL = 5000; // 这里可以手写任何逻辑,重新生成时会原样保留 // t3code:end 手工代码区结束 const [data, setData] = useState<Order | null>(null); // ... }t3code 解析已有文件时,找到t3code:start和t3code:end之间的内容,存到内存里;重新生成时再把这段内容原样写回。这个方案不完美——手工代码区内的代码无法感知外部自动生成的变化——但实际用起来足够了。至少解决了“辛辛苦苦写的 localStorage 缓存逻辑被一键删除”的痛点。而且这个方案的好处是模板里不用引入任何复杂的宏语法,一个正则就能搞定。
5.5 把 t3code 挂进 CI:防止手改与代码过期
自动生成工具最容易遇到的问题不是生成失败,而是生成结果和 schema 脱节。有人改了后端接口,没有更新 schema,前端代码里类型还是旧的,接口对接全靠脑子硬记。为了根治,我们在 CI 里加了t3code --check环节:每次代码提交之前自动跑一遍生成器,比较此次生成的代码和仓库里的现有代码,如果有差异就报错,提示“schema 已变更,请重新执行 t3code 并提交生成文件”。
本地 pre-commit hook 用的是 lint-staged,配合git diff做快速校验。这个机制跑起来之后,再也没出现过 schema 改了前端没同步的情况。工具生成代码这件事,最怕的就是“生成代码没有进入版本控制”,所以在 Git 提交链路里强制卡一道门槛是最有效的手段,比任何约定都靠谱。
最后说点个人体会
t3code 这个工具走到现在,最大的教训是:一开始不要想着做一个“平台的平台”,而是先解决团队里最痛的一个具体问题。我们就是从手写接口类型开始,一步步加上了 API 封装、Hooks 生成、CI 检查、保留区块,每次都是被真实需求逼出来的。
如果你也想做类似的东西,我的建议是先跑一个最小版本:读一份 schema、生成一个类型文件、接一个真实接口,哪怕代码写得丑一点都行。只要这个链路能跑通,后面所有功能都只是增量演化。反过来,如果一开始就纠结高可用、多语言支持、配置系统,大概率项目会烂在半路。工具服务于人,能解决一个问题,比什么都强。