差不多半年前,我被一个字段名折磨到凌晨两点。
后端老哥把接口返回里的user_name改成了userName,文档和实际响应对不上,前端这边编译一个错都不报,结果一上线页面白了一块。最后抓包才发现,问题根本不在业务逻辑,而是两端对"契约"的理解完全不一致。这种经历搞全栈的人都懂:前后端各维护一套类型,各写各的校验,接口一变就靠人肉消息同步,字段一多就开始互相猜。
t3code 就是在我被这套流程反复折磨之后写的一个内部小工具。它的核心思路很简单:把类型定义做成唯一事实源,再从这个源头自动生成前端类型、请求函数、运行时校验和 mock 数据。这篇文章我会把 t3code 的完整设计思路、落地细节和踩坑记录全部摊开讲。如果你每天要在前后端之间手写胶水代码,或者正想给团队引入"类型先行"的工作流,这篇应该能给你不少参考。
1. 从一次线上数据空白说起:t3code 要解决的不只是"写代码慢"
聊技术方案之前,我想先还原一下当时的真实场景,因为很多工具的理念只有放在具体场景里才容易被理解。
1.1 前后端类型割裂的日常现场
我们当时是一个三人小组,前端两个,后端一个,项目是标准的 Next.js + Express 组合。前后端之间的约定靠的是 Apifox 文档加上一个共享 Markdown 文件,里面写着接口的路径、参数、响应结构。听起来还行,但实际操作中问题层出不穷:
- 后端用
snake_case定义数据库字段,前端喜欢camelCase,中间需要手动转换,漏转一个就出 bug。 - 分页结构每个接口都不一样,有的是
{ list, total },有的是{ items, count },每个接口都要重新写一遍类型。 - 可选字段和 nullable 字段混用,前端以为某字段一定存在,运行时却是
undefined。 - 接口文档更新滞后,前端照着旧文档写代码,联调时才被 400 拍醒。
这些问题的共同根源是:类型定义不是一个唯一的源,而是散落在两个项目里、被两拨人各自维护的重复资产。编译期谁都不会帮你看,因为这是跨进程、跨语言的边界。
1.2 现成方案为什么不够顺手
在写 t3code 之前,我也试过市面上现成的工具,它们的思路都不错,但总有让我难受的地方。
先用过 OpenAPI 手写合约再用openapi-typescript生成前端类型,生成的类型质量尚可,但问题是 OpenAPI 文件本身维护成本很高,一遇到嵌套组合类型就要写一长串$ref,而且运行时校验这一步始终缺位。类型能生成出来,但接口返回的数据是不是真的符合类型,依然没人管。
也考虑过 GraphQL codegen,类型和校验都强,但为了一个内部项目引入整套 GraphQL 生态,改造成本太大,后端也不是 RST 风格,杀鸡用牛刀。
最朴素的做法其实是手写 TypeScript 类型加 Zod 校验器,前端z.infer<typeof User>拿到类型,运行时用UserSchema.parse兜底。但这种方式仍然有一个致命伤:类型和校验器重复维护,同一个用户实体,interface 一份、Zod 一份,改字段时经常只改了一边,两边又对不齐了。
1.3 t3code 的定位:契约即代码
t3code 的定位就是解决上面这些"重复维护"和"类型与校验分离"的痛点。它不是一个框架,不要求你的技术栈做什么大改造,它只是一个命令行工具:你维护一份用 Zod 定义的 schema 文件,它帮你生成四样东西——类型定义、运行时校验器、请求函数、mock 数据。
要求就一个:schema 是唯一的真相,任何人都不允许手写接口的返回类型。
我把这个思路叫做"契约即代码"。以前契约是文档,是聊天记录,是记忆力;现在契约是一段可编译、可校验、可 diff 的代码。改接口不再是"后端改完喊一声,前端猜一猜"的沟通问题,而是"schema diff 里写明白改了啥"的工程问题。
提示:t3code 不是银弹。它适合你有明确前后端边界、且希望把类型安全推向运行时的场景。如果你的项目只是一个单体应用内部调用,或者接的是第三方黑盒 API,这套思路未必划算,后面第 5 章我会仔细聊适用边界。
2. 类型先行:t3code 把"单一路径"变成了三层能力
t3code 之所以能用一个 schema 长出那么多产物,核心在于它把"类型安全"拆成了三个层次,每一层干的事完全不同。这里我展开讲讲,理解了这层逻辑,后面看实现才不会懵。
2.1 共享拓扑:类型、校验器、客户端代码从同一份 schema 长出
在传统的全栈开发里,一个数据实体要经历四段生命:
- 数据库表结构(后端定义)
- 接口返回结构(后端手写)
- 前端 TypeScript 类型(前端手写)
- 前端运行时逻辑(前端手写)
这四段之间每一段转换都是 bug 的温床。t3code 的做法是把它压缩成一段:
schema(Zod 对象) ├── 编译期类型(供 TS 静态检查使用) ├── 运行时校验器(供接口入参/出参 parse 使用) ├── 客户端请求函数(封装 fetch + 校验) └── mock 数据生成器(供前端脱离后端联调)所有产物都由同一套 schema 一步步长出来,源头只有一个。这个结构的好处在你做了第 4 章的坑之后会明显感受到:只要源头是对的,所有产物要么全部更新,要么全部一致,不存在"改了 A 忘了 B"的中间状态。
2.2 为什么选用 Zod 作为 schema 载体
这个问题几乎每次跟同事聊都会被问:为什么不直接用 TypeScript 的interface?为什么要为 schema 单独引入一套库?
原因是interface做不了两件事。
第一,interface只是编译期类型,运行时会消失。如果你依赖interface定义契约,前端拿到的只是"类型声明",没法在运行时证明"这个数据真的符合类型"。而 Zod 对象是运行时存在的值,z.object({...})本身就是一个可执行的校验器。
第二,Zod 能够从一个运行时对象反向推导出编译期类型。同一个UserProfileSchema,z.infer就能得到UserProfile类型,不需要单独维护 interface。等于一道菜做出来,既能吃(运行时校验),又能当菜谱用(编译期类型)。
当然我承认 Zod 不是唯一能做这件事的库,Yup、Valibot 也能做类似的事,但 Zod 胜在生态和类型推导的完整度。尤其递归类型、枚举、可空字段这些场景,Zod 写出来比较直白,生成器也好处理。
2.3 编译期类型和运行时校验的关系
很多初学者会把编译期类型和运行时校验搞混,觉得"我用了 TypeScript 就是安全的"。实际上这两者护住的边界完全不同:
- 编译期类型护住的是"你的代码内部"。如果前端自己写的业务代码里,拿一个
string传给了需要number的函数,TS 编译器会拦住你。 - 运行时校验护住的是"程序信任边界"。接口返回的数据从后端传到前端,这中间经过了网络、序列化、反序列化,没人能保证它和类型声明完全一致。此时
UserProfileSchema.parse(json)才是真正的兜底。
t3code 在生成客户端请求函数时,默认会对响应做一次 parse,就是为了把这条信任边界守死。代价是每条请求多了一次运行时校验的开销,但换来的是"线上白屏"那种问题几乎绝迹,我认为非常划算。
经验:在 schema 设计上我强烈建议只用
z.object、z.string、z.number、z.enum这些声明式 API,尽可能少用.transform()。transform 会让 schema 同时承担"校验"和"改数据"两个职责,生成器不方便处理,团队理解成本也高。数据形状转换放到业务层去做,schema 保持纯净。
3. 从零跑通 t3code:一次完整的生成链路
说完了理念,来看看实际操作。这一章我会用一个"用户注册"场景,把 t3code 从初始化到产物生成完整过一遍。整个流程我尽量写得具体,你可以直接照着跑。
3.1 初始化与目录约定
t3code 采用 monorepo 式的目录约定。我的项目里大概是这样的结构:
apps/ web/ # Next.js 前端 server/ # Express 后端 packages/ schemas/ # t3code schema 定义 generated/ # t3code 生成物 cli/ # t3code 命令行工具在仓库根目录执行npx t3code init,会在packages/schemas下生成一个配置文件t3code.config.ts:
import { defineConfig } from '@t3code/cli'; export default defineConfig({ schemaDir: './packages/schemas', outputDir: './packages/generated', runtime: 'zod', clientTarget: 'fetch', generate: ['types', 'validators', 'client', 'mock'], });这里的generate数组控制要生成哪些产物。初期可以全开,跑通之后再按需裁剪。注意clientTarget: 'fetch',因为前端用的是 Next.js,fetch 天然可用,不需要额外引入 axios 之类的库。
3.2 用 schema 定义一个用户注册场景
schema 目录下写一个user.ts,内容就是一个普通的 Zod 对象集合:
import { z } from '@t3code/schema'; export const UserProfile = z.object({ id: z.string().uuid(), name: z.string().min(1), email: z.string().email(), avatarUrl: z.string().url().optional(), role: z.enum(['admin', 'member', 'guest']), }); export const UserApi = { register: { path: '/users', method: 'POST', request: z.object({ name: z.string().min(1), email: z.string().email(), }), response: UserProfile, }, };我习惯把UserApi的字段命名为接口标识,method是 HTTP 方法,path是路径,request和response各自对应入参和出参。所有接口都按这个结构声明,t3code 就能统一解析。
3.3 生成了什么:类型、客户端、模拟数据
跑npx t3code gen之后,packages/generated下面会多出几个文件。我最常关注的是user.client.ts,大概长这样:
// AUTO-GENERATED by t3code. Do not edit manually. import { UserProfileSchema } from './user.validators'; export async function registerUser( body: { name: string; email: string }, init?: RequestInit, ): Promise<UserProfile> { const res = await fetch('/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), ...init, }); if (!res.ok) { throw new Error(`registerUser failed: ${res.status} ${res.statusText}`); } const json = await res.json(); return UserProfileSchema.parse(json); }这个函数的特点有三个:
一是入参部分,TS 会给上{ name: string; email: string }的静态类型约束,调用方传错字段编译期就能发现。
二是出参部分,返回的Promise<UserProfile>类型来自 schema 推导,不再需要前端手动声明。
三是UserProfileSchema.parse(json),响应进入前端业务代码之前,运行时先校验一遍,不合格立刻抛错。
mock 数据生成器又是另一回事。它会根据 schema 的字段类型自动产出符合规则的模拟数据:
// AUTO-GENERATED mock for user.register.response export function mockUserProfile(): UserProfile { return { id: 'c5e7f0a8-xxxx-xxxx-xxxx-xxxxxxxxxxxx', name: 'mock-user', email: 'mock@example.com', role: 'member', // avatarUrl 是可选字段,mock 里大概率不生成 }; }这个功能在前后端并行开发时特别有用。前端不需要等后端接口就绪,直接 import mock 数据联调页面,后端好了再切到真实接口。
3.4 watch 模式下的增量更新
在开发阶段我一般会开npx t3code dev,它会监听packages/schemas下的文件变化,一旦 schema 有改动就自动重新生成产物。这样前端在写代码的时候,如果接口返回的字段变了,重跑编译立刻能看到新类型,错误提示指向性很强。
这里要提醒一个 baseline 层面的问题:如果生成物提交到了 git(我默认是提交的),那 watch 模式下每次改动都会产生一次文件变更,提交记录会有点碎。我的习惯是 schema 改完跑一次手动gen,确认产物没问题后连同 schema 一起提交,避免中间态污染历史。
4. 开发 t3code 时踩过的六个坑
说句实话,t3code 本身写起来没多复杂,真正花时间的是各种边角场景。这一章我把开发过程中最有价值的几个坑列出来,每个都包含现象、定位过程和解法,希望能帮你省点时间。
4.1 生成的代码里出现了 undefined 类型:循环引用与延迟解析
现象很直接:我定义了一个树形结构的 schema,比如TreeNode的children是TreeNode[]。第一次gen还正常,加了递归之后生成的类型突然变成了unknown或undefined,运行时 parse 到了深层节点就直接炸。
定位过程很经典。先以为是 Zod 版本问题,翻文档发现递归要求在字段上写z.lazy(() => TreeNode),否则 Zod 在初始化阶段根本解析不了自引用。我最初是用z.array(z.any())来绕过,类型自然退化了。
正确写法是:
export const TreeNode = z.object({ label: z.string(), children: z.array(z.lazy(() => TreeNode)).optional(), });在 t3code 的生成器里,遇到z.lazy要做特殊处理,不能把它当作普通字段类型直接展开,否则会无限递归。我的解法是给lazy内的类型生成一个字符串别名,在类型声明里用别名引用,运行时才去解析真实结构。测试之后递归 20 层深都还能正确 parse。
4.2 构建产物和源码版本不一致:type-only 导出问题
这个坑出在把 t3code 的生成物发布成 npm 包之后。现象是:源码里UserProfile明明是从z.infer<typeof UserProfileSchema>推导出来的,前端引用时却报"类型不存在"或者类型变成了any。
查了一圈发现是tsconfig里isolatedModules和exports配置的问题。生成器输出的.d.ts文件里,把类型和值混在同一行导出了,比如:
export { UserProfileSchema, type UserProfile } from './user';旧版工具链对type关键字的处理不一致,导致构建时把类型导出当成普通导出,最终在运行时模块里找不到类型定义。
解法是生成器统一把所有类型改写为独立的export type语句,并且显式声明.d.ts文件的export type *路径。这个坑也让我养成了一个习惯:生成物不能只看.ts是否符合预期,还要检查构建后的.d.ts是否干净。
4.3 打包工具把 Zod 校验器打重复:bundle 与 external 的取舍
早期 t3code 的 CLI 是用 esbuild 直接打包成一个二进制的,这样用户用起来方便。但问题来了:如果前端项目本身也依赖了 Zod,而 t3code 生成的代码里又内嵌了一份 Zod,运行时就会同时存在两个 Zod 实例,instanceof校验会失败,parse 规则偶发失效。
定位过程比较隐蔽。现象是 schema 相同的两个校验器,一个在浏览器端正常,一个在 Node SSR 环境里报错,报错信息是zod.instanceOf is not a function之类。逐个排查之后确认是 Zod 被打了两次包,实例不一致。
解法是把 Zod 从 bundle 中剔除,在package.json里声明为peerDependencies或external,让 t3code 生成代码直接引用项目自身的 Zod。这样虽然要求使用方必须手动安装 Zod,但保证了实例唯一性。
经验:涉及此类"生成了代码"的工具,尽量让产物依赖宿主项目的依赖,而不是内嵌自己的运行时。内嵌一时爽,维护火葬场。
4.4 watch 模式在 Windows 上反复触发:文件监听的大小写问题
这个坑是团队的 Windows 同事报的:在 Windows 上跑t3code dev,一旦改动一个 schema 文件,生成过程会循环触发,终端疯狂刷日志,直到手动终止。
原因和 Linux 下完全不同。Windows 文件系统大小写不敏感,watch 库监听的是某个目录,当 t3code 把生成物写入packages/generated目录时,如果这个目录恰好又在监听范围内,文件变动事件就会再次触发"重新生成",而生成动作又写入文件,于是无限循环。
解决思路分两步。第一步是生成物目录默认排除在监听范围之外,通过配置文件ignore: ['./packages/generated']强制忽略。第二步才是关键:schema 文件改动后,先做一次内容 hash 对比,只有实际内容变化才触发重新生成。这样即便监听误触发,也会因为 hash 没变而跳过。
4.5 参数个数不同的函数重载导致类型退化为 any
生成客户端请求函数时,我一开始用函数重载把"带 body 的 POST"和"query 参数的 GET"统一成一个函数签名。结果在业务代码里调用时,如果少传了一个参数,TS 不但不报错,反而把整个入参推断成了any,直接绕过了 t3code 的静态类型保护。
这个现象的本质是 TypeScript 函数重载的匹配机制:当多个重载签名都存在,编译器会找一个"最宽松"的匹配,极端情况下会启用any逃逸。我花了很长时间才意识到问题不在编译器,而在生成器的设计。
最终我放弃了重载,改为针对不同 method 生成不同的函数名,比如getUser、registerUser、updateUser,每个函数只接受自己所需的参数类型。函数重载的"便捷"远不如类型安全的"可靠",这是我这次踩坑最大的领悟。
4.6 Monorepo 依赖漂移:选择固定版本号而非范围版本号
最后一个坑不算技术难点,但很坑人。t3code 在 monorepo 里跑,各个 package 都依赖了 Zod,一开始package.json里写的是"zod": "^3.20.0"。过了一段时间,某个子包升级到了 3.23,另一个还锁在 3.20,两个版本的 zod 共存,生成的代码里出现"看起来都是 Zod,但类型系统认为它们完全无关"的诡异错误。
修这个问题的成本并不高,但排查过程很烦:依赖树里出现了两份 zod,一份在 app 下,一份在 t3code 生成的代码下。最后我用pnpm-dedupe把重复版本收拢,然后把所有 zod 依赖改为精确版本号"3.23.8",再不许使用^范围。
这个教训对任何 monorepo 工具链都适用:共享运行时依赖一定要锁死精确版本,范围版本在 npm 世界里看着方便,实际上是漂移的根源。
5. t3code 实测结果与适用边界
工具写出来总得经得起现实检验。这一章我从数据、适用场景和协作规范三个角度介绍 t3code 的实际表现。
5.1 一个小项目的对比数据
为了验证 t3code 到底值不值得继续用,我在一个内部管理后台项目上做了对比。这个项目有 30 个左右的接口,涵盖用户管理、角色权限、日志查询三块。对照组是之前用"手写类型 + 手写请求函数"方式实现的旧版,实验组是重构后用 t3code 生成的新版。
粗算结果是这样的:
| 对比项 | 手写实现 | t3code 生成 |
|---|---|---|
| 契约相关代码量 | 约 1200 行 | 约 800 行(含 schema) |
| 联调返工次数 | 11 次 | 2 次 |
| 漏改字段引发的线上问题 | 3 次 | 0 次 |
| 新增一个接口平均耗时 | 2 小时左右 | 30 分钟 |
代码量减少不算夸张,真正的收益在联调和线上问题。一个接口写完,schema 就是最终的契约,前后端拿着同一份生成物,字段对齐这件事从"靠人聊"变成了"靠编译器复查",返工次数骤降是意料之中的。
不过要注意,t3code 不是让代码量减少了 30%,前端本来该写的业务逻辑一点没少,它省的是"胶水代码"和"沟通过程"。
5.2 什么时候不该用 t3code
工具都有边界,t3code 也一样。如果你遇到下面这些情况,我建议先别急着引入:
- 项目只有一个进程,后端和前端共享代码仓库但边界模糊。t3code 解决的是跨边界契约问题,单体内不需要多一层生成器。
- 前端并非 TypeScript。schema 产物直接依赖 TS 类型系统,纯 JS 项目用不上。
- 第三方接口占主力。对方不维护你的 schema,生成器只能帮你生成调用代码,没法控制上游。
- 契约极度动态,比如字段完全由用户自定义、没有固定形状。Zod 的
z.record和z.any能兜底,但兜底之后生成器的意义就淡了。
我自己的经验是,t3code 最舒服的使用场景是前后端都是 TypeScript、有一个方愿意为 schema 变更负责。没有这个前提,工具链会变成第三份没人愿意维护的资产。
5.3 团队协作要求:schema 变更流程和生成物是否提交
工具之外,我琢磨最久的其实是协作规范。t3code 能跑起来容易,跑得好需要约定。我目前维护三条:
第一,schema 变更必须走 code review。改接口不再是在聊天群里喊一声,而是提交一个包含 schema 变更的 PR,让对端通过 diff 来确认变化。
第二,生成物提交 git。所有 t3code 产物都落版本库,这样任何一次接口变更都能在 PR 里看到对应的代码变化,方便审计和 revert。有人认为生成物不该提交,我试过不提交的方案,结果是各机器生成的产物时不时有细微差异,反而更难查。
第三,schema 是只读共享资产。谁也不允许在各自的 app 里手写接口类型。一旦发现有人为了图方便直接在业务组件里定义了一个"本地接口类型",我会在评审里打回去。这是唯一能保证单一事实源长期不被破坏的办法。
5.4 从 t3code 里提炼的三条通用原则
最后说说做这个工具给我带来的方法论层面的收获,即使以后你不用 t3code,这三条在很多项目里也适用。
第一条,在所有进程边界上做运行时校验。编译期类型只是内部约束,一旦数据跨了进程、跨了网络,就必须有一个 actul 的校验器来兜底。这是 t3code 最核心的安全观。
第二条,单一事实源比 DRY 更值得坚持。DRY 说的是"不要重复自己",但如果没有一个权威源,DRY 最后会变成"复制粘贴之后长期没人清理"。t3code 做的就是把"契约"抬到唯一源的位置,其他都从它生长。
第三条,生成物要有可审查的 diff 和清晰的版本对应。自动生成不是黑魔法,生成出来的代码必须能进 git diff,能退版本,能和 schema 版本一一对应。否则一旦出了线上问题,你连"当前跑的代码是谁生成的"都答不上来,排查会很痛苦。
对我来说,t3code 这几百行代码最有价值的收获不是少写了多少胶水,而是把"改接口"从消耗团队信任的沟通活,变成了一份可审查、可回滚的工程产物。这条路走到现在,我还在琢磨几个扩展:比如往 OpenAPI 导出方向靠,让非 TS 的后端也能消费同一套 schema;再比如做一个独立的 mock server,把 mock 数据直接通过 HTTP 吐给前端,这样前端联调连 import mock 函数都不用改。
如果你也在做类似的事情,我建议不要一上来就全量引入,先挑一条最简单的接口链试,感受一下"改 schema、跑生成器、看 diff"这个闭环。跑通之后你会回来告诉我,前后端联调这件事,确实可以有另一个打开方式。