接手一个老项目的时候,我最怕的不是业务逻辑写得乱,而是前后端的数据契约全靠人肉同步。后端把字段从userId改成accountId,前端还在用旧类型,页面一打开就是undefined报错,排查半天发现根因根本不在页面里,而在类型定义和实际数据已经脱节了。后来我把 t3code 引进了工作流,问题被按下去了不少。这篇就从头到尾聊聊,我在真实项目里是怎么用它把类型绑定这套事理顺的,以及中途踩过哪些坑。
t3code 是一套面向 TypeScript 全栈项目的代码生成工具链,核心思路很简单:把接口契约、数据库模型、请求参数这些“类型源头”抽出来,自动生成前端可用的类型定义和 API 调用代码。如果你正在做前后端分离的项目,尤其是 monorepo、微前端、多端复用类型这种场景,这篇文章值得你花十分钟看完。后面我会把原理、配置、实操步骤,以及那些官方文档里不会写的血泪教训都展开讲。
1. 为什么我会把 t3code 放进新项目的第一天
1.1 类型不同步的痛,比 bug 本身更磨人
先说个我自己的例子。去年我们做了一个运营后台,React + Node.js,前后端各一个仓库。项目初期为了快,接口类型全部手写。前两周还好,等业务进入密集迭代期后,问题就开始冒头了:后端同学为了满足一个报表需求,把列表接口的返回从扁平对象改成了分页结构,前端没人知道。结果就是列表页白屏,控制台报错指向一个根本不存在的数据路径。
这种问题不发生在业务逻辑上,而发生在“类型信息没有跟着代码一起流动”这件事上。接口文档倒是维护了,但文档是死的,代码是活的,只要有一步没跟上,整个信任链条就断了。t3code 吸引我的第一个点就是:它让类型变成可以自动生成的产物,而不是靠记忆和自觉去同步的东西。
1.2 t3code 到底管哪一段
我理解的 t3code,不是脚手架,也不是框架,它管的是“应用的数据进出口”这一段:往上是业务代码,往下是数据库和外部服务。它做的事情可以压缩成三件事:
- 读取你定义的契约源(比如 OpenAPI 文档、数据库 Schema、Zod 模型、GraphQL Schema)
- 生成对应的 TypeScript 类型文件和服务端/客户端调用代码
- 在开发期监听变化增量同步,在 CI 里做一致性校验,防止两边悄悄漂移
注意一个关键词:契约源。t3code 不是凭空的代码生成器,它要求你先有一个权威的数据定义。这个定义可以是后端同学维护的 OpenAPI yaml,也可以是 Prisma schema,甚至是一组 Zod 对象。只要这个源头是准的,生成出来的前端代码就是准的。
1.3 谁适合直接上这套,谁可以再观望
如果你符合下面几条,我建议你直接在第一天就把 t3code 接进来:
- 项目是前后端分离,且 API 数量超过二三十个
- 有多个消费端(Web、小程序、管理后台)需要复用同一套类型
- 团队里前后端协作频繁,接口经常调整
- 你们已经在用 monorepo,类型共享却还靠复制粘贴
反过来,如果项目很小、接口就五六个、两三天就上线,那手写类型反而更快。工具链是有成本的,t3code 也不例外。你不要为了用工具而用工具,先判断痛点是否真实存在。
2. 安装与初始化:跑通 t3code 的最低成本路径
2.1 环境准备与版本选择
t3code 是 Node.js 生态里的 CLI 工具,安装前提就是你得有 Node.js 环境。我建议 LTS 版本,不要用太新的奇数版本,免得遇到莫名其妙的原生依赖问题。装的时候我习惯把它放在 devDependencies 里而不是全局安装,这样每个项目锁的版本是独立的,不会出现这台机器能用、那台机器用不了的尴尬情况。
# 在项目根目录安装 npm install -D t3code # 或者用 pnpm pnpm add -D t3code如果是团队项目,强烈建议锁版本。我们后来把t3code的版本固定在了 package.json 里,并且用 lockfile 统一,省了很多“你那边生成的跟我这边不一样”的扯皮。
2.2 一份能直接抄的初始化配置
装完之后,第一次运行t3 init会生成一个配置文件,默认叫.t3rc.json。我先给一份我压箱底的配置模板,你可以直接抄:
{ "source": { "type": "openapi", "entry": "./contracts/api.yaml" }, "generates": [ { "target": "./packages/client/src/generated", "language": "typescript", "client": "fetch", "include": ["*.ts", "*.d.ts"] } ], "watch": true, "formatOnGenerate": true, "strict": true }解释一下几个关键字段:
source:定义契约源从哪里读。我这边用的是 OpenAPI,因为后端团队已经习惯维护 yaml 文档了。generates:输出位置和语言配置。client: "fetch"表示生成基于 fetch 封装的请求函数,也可以换成 axios。watch:开发模式下监听源文件变化,自动增量生成。strict:开启后,如果契约里有不合法的类型结构,直接报错,而不是跳过。
2.3 生成的产物长什么样
跑完t3 gen之后,你的目标目录里会出现类似下面的结构:
packages/client/src/generated/ ├── api.ts ├── types.ts └── client.tstypes.ts里是从契约里提取出来的所有接口类型,比如一个用户接口会对应一个User类型和一个GetUserResponse类型。client.ts是封装好的请求函数,参数类型已经和 types 关联。api.ts是入口,把每个 domain 的接口聚合到一起。
实际开发里,我大部分时候只手动去读types.ts,调用直接走api.ts导出的函数。比如:
import { getUser } from "../generated/api"; const user = await getUser({ id: "123" }); // user 的类型是 GetUserResponse,结构已经跟后端对齐过程中几乎不用看网络请求咋写的,header 怎么带、错误怎么抛,生成代码里都有默认实现。
3. t3code 的工作原理:从 schema 到类型绑定的编译链路
3.1 一次生成,还是持续同步
很多人第一次用生成器,会把它当成“一次性脚手架”来用:跑一次,生成代码,然后就把生成代码当手写代码去改。坏处很明显——下次源文件一更新,你手动改过的地方全被覆盖了。
t3code 的设计理念不是让你“生成一次”,而是“持续同步”。它有两种工作模式:
- 一次性生成:适合 CI、发布前校验
- watch 模式:适合开发期,源契约一变,它就自动重新生成
这里有个关键点,请务必记住:生成目录里的代码是产物,不是源文件,永远不要手改。你如果有定制需求,不要直接改产物,应该改模板或加注入点。
3.2 模板系统与自定义注入点
t3code 内部用了一套类似 AST 解析 + 模板渲染的流程:先把 OpenAPI/Prisma schema 解析成中间结构,再把中间结构喂给模板,渲染成目标语言文件。它内置的默认模板足够覆盖大部分场景,但也支持自定义。
我给大家演示一个最常见的自定义场景:我们希望生成的请求函数自动带上租户 ID 请求头。配置里加一段hooks:
{ "hooks": { "beforeRequest": "./hooks/add-tenant.ts" } }然后在add-tenant.ts里写:
export function beforeRequest(config: RequestConfig): RequestConfig { config.headers = config.headers || {}; config.headers["X-Tenant-Id"] = getCurrentTenantId(); return config; }这种做法的好处是:你不需要去读生成出来的那一大坨 client 代码,只要在固定的注入点写自己的逻辑就行。升级 t3code 版本也不会把你的定制逻辑冲掉。
3.3 配置为什么这样设计:约定大于配置的另一面
我一开始也觉得 t3code 的配置有点多,但随着用的次数多了,我发现它的设计其实是在“约定优于配置”和“可定制性”之间做了平衡。默认情况下,你只要指定 source 和 generates,剩下的大多数选项都有合理默认值。比如:
- 命名规范默认是 camelCase,除非你在配置里改了
caseStyle - 请求函数默认基于 fetch,Node 18+ 直接能用
- 错误响应默认抛
ApiError,统一结构
你不需要一开始就把所有配置背下来,先跑默认,遇到需求再查文档加选项。工具是为人服务的,别反过来被配置绑架。
4. 上线前我踩过的坑和排查思路
4.1 生成的代码被 git 反复摩擦:目录归属问题
最开始我们直接把生成目录提交进了 git,结果每天都有大量 diff:今天后端改了描述,明天加了个字段,diff 里全是生成代码的变化,code review 根本没法看。
后来我们走了两步解决:
- 在
.gitignore里把generated目录忽略掉 - 在 CI 里加一道
t3 check命令,它不重新生成,只校验当前工作区的代码和契约源是否一致,不一致就 fail
这样本地开发仍然是全自动的,但提交记录干干净净,review 的人只看真实的业务改动。
4.2 monorepo 下路径别名失效:一个晚上才排查完的坑
我们项目是 pnpm workspace,tsconfig 配了路径别名@/指向src。t3code 生成的文件也在src下,本来应该没问题,但我发现生成的代码在使用别名导入时,IDE 能解析出来,tsc却一直报模块找不到。
排查了半天,最后发现是生成的tsconfig.json片段没有继承根目录的baseUrl和paths。t3code 默认会在输出目录里生成一个tsconfig.json,如果你的别名配置只在根目录,这个子目录的编译环境就断了。
解决方式是在配置里加:
"extendsTsconfig": "../../tsconfig.base.json"让它生成的tsconfig.json去继承根配置。这个坑非常隐蔽,因为大多数编辑器会向上找 tsconfig,ESLint 也正常,只有执行tsc --noEmit才暴露。
4.3 watch 模式在 CI 里的陷阱
还有一个我提醒过很多次的:不要在 CI 里开watch: true。watch 是常驻进程,CI 环境里跑起来不会自动退出,构建任务会一直挂住。我们的 CI 脚本一开始写得省事,直接复用本地命令t3 gen --watch,结果流水线超时了三次才反应过来。
正确做法是:
# 本地开发 t3 dev # CI 里只用一次性生成 + 校验 t3 gen t3 check每次项目新同学接手的时候,我都会在 README 里写清楚这两个命令的区别。这个看起来很小的事,真的能省下不少无谓的等待。
5. 和手写接口层相比,t3code 的取舍在哪里
5.1 什么样的项目收益最大
我用一个简单的对照表来说话:
| 维度 | 手写类型 + 手写请求 | 手写生成器 | t3code |
|---|---|---|---|
| 初始成本 | 低 | 高(要自己写模板引擎) | 中 |
| 类型同步性 | 差 | 中 | 好 |
| 升级维护成本 | 低 | 高 | 中低 |
| 多端复用 | 靠复制 | 看实现 | 原生支持 |
| 上手门槛 | 无 | 高 | 中低 |
如果是业务系统、管理后台这类接口多、字段层级的项目,t3code 的收益非常明显:前端同学不需要追着后端问字段含义,类型定义里全都有;后端改了契约,前端打开 IDE 立刻看到类型报错,问题在开发期就暴露了。
5.2 什么时候我会不推荐用它
说实话,也有不推荐的情况。比如:
- 团队完全没有统一的契约源,后端接口文档靠口口相传
- 项目是一次性原型,活不过三个月
- 团队里没人愿意维护
.t3rc.json,工具沦为摆设 - 你们已经有了成熟的自研生成器,核心需求都满足了
t3code 再怎么说也是个工具,工具要服务于团队协作方式。如果团队连最基本的契约意识都没有,接什么工具都白搭。我个人的原则是:先有契约约定,再上自动化,顺序不能反。
还有一点,如果你所在的项目类型不是 HTTP API,比如纯 GraphQL 或者 gRPC,t3code 的核心链路不一定能直接覆盖。GraphQL 有自带的 codegen 生态,gRPC 有 protobuf 生成器,跨工具的迁移成本要提前评估。
最后再分享一个我在实践中摸索出来的用法:把 t3code 和 lint-staged 结合,在 commit 之前跑一次t3 check,把类型漂移拦截在提交之前。这个操作让我项目的线上报错率降了一截,配合错误监控一起用,效果比想象中更好。如果你也正在被前后端类型同步折磨,不妨试着把它接进工作流里。