资讯动态

t3code:从接口描述自动生成 TypeScript 类型与 React Hooks 的代码生成工具

发布时间:2026/10/28 7:15:38 来源:尧图企业网站定制
如果只是偶尔写一次接口对接我不会专门写 t3code 这个工具。但当你连续五六个需求都在做同一件事——对着接口文档把字段一个个敲成 TypeScript 类型抄一遍 URL 再包一层请求函数然后回到组件里写 loading、错误处理、重新拉取——你也会想找一个能一键完成这些重复步骤的东西。t3code 就是我们团队花了三个月时间打磨出来的答案。t3code 读作 T-three-code含义是 TypeScript、Type-Safe、Template核心功能非常直接喂一段接口描述自动生成类型定义、API 封装函数和 React Hooks 三份代码。这篇文章会把它的诞生背景、设计思路、核心实现和踩过的坑完整过一遍适合正在维护中后台项目、天天跟各种 REST 接口打交道的人也适合在做前端基建、写脚手架工具的同学参考。1. 被重复的接口对接逼出来的 t3code先从团队里的脏活说起1.1 T3 这个前缀从哪里来我们团队的技术栈是社区里常说的 T3 StackNext.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 文档内部老接口根本不具备orvalReact 集成好支持 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 Hooksindex.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 Setstring(); 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 嵌套 BB 嵌套 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): Promisestring { 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): PromiseOrder { 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] useStateOrder | null(null); const [loading, setLoading] useStateboolean(false); const [error, setError] useStateError | 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.nameTypeScript 立刻报错“对象可能为 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] useStateOrder | 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、生成一个类型文件、接一个真实接口哪怕代码写得丑一点都行。只要这个链路能跑通后面所有功能都只是增量演化。反过来如果一开始就纠结高可用、多语言支持、配置系统大概率项目会烂在半路。工具服务于人能解决一个问题比什么都强。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑