凌晨两点,我盯着屏幕上那条报错,心里只剩一个念头:再也不手写样板代码了。
当时我正在维护一个基于 T3 代码栈(Next.js + tRPC + Prisma + Zod)的中型项目,数据模型里的字段从字符串改成了枚举,Prisma 这边改完,忘了同步 tRPC 路由里的 Zod schema。结果前端提交订单时,服务端校验直接把请求给拦了,线上反馈接二连三。问题不算大,但让我意识到一个事实:整个 T3 生态最引以为傲的"端到端类型安全",恰恰在最容易出错的环节——模型、校验、路由、前端类型之间的手工同步——把压力全留给了开发者。
我就是为了解决这件事写的 t3code。它是一个面向 T3 代码栈的 CLI 代码生成与同步检测工具,核心思路是把 Prisma 数据模型当作唯一事实来源,自动生成 Zod 校验 schema、tRPC 路由模板、前端类型定义和 API 调用封装,顺带在 CI 里帮你强制检查"模型改了但代码没跟上"的情况。如果你正在用 Next.js 和 tRPC 写全栈应用,又被重复的 CRUD 样板拖得心烦,这篇文章应该能给你一点实际的参考。
1. T3 代码栈里的"重复劳动":从一次半夜事故说起
T3 代码栈用着确实爽,路由、数据库、校验全在 TypeScript 的类型系统里闭环,几乎每个环节都有类型兜底。但"闭环"不等于"自动",链条里所有的连接件都需要你手写去接:Prisma 模型定义了数据库结构,Zod 要单独写一份输入校验,tRPC 路由里要写查询和变更逻辑,前端还得自己挂 React Query 的 hook 才能消费这些 API。四套代码描述同一个实体,任何一处不一致,线上就会给你颜色看。
1.1 那一次的线上故障:一次字段改名引发的连锁反应
说回开头那次事故。业务上我需要把订单的status字段从字符串改成联合类型:PENDING | PAID | SHIPPED | COMPLETED | CANCELLED。Prisma 模型里改枚举,几分钟的事;数据库迁移,顺手做了;但 tRPC 路由的入参校验里,对应的 Zod schema 还是老样子——z.string()。前端按新枚举提交"PAID",服务端校验自然通过,问题出在另一个老接口:某个回调逻辑还在传旧字符串"paid"(小写),Zod 直接拒绝,订单状态更新全部失败。
排查过程不算难,schema 不匹配的报错一眼就能定位。但这件事让我真正难受的是:这类问题几乎不可防。只要模型和校验之间存在手工同步,就永远存在"改了一处、漏了一处"的可能。TypeScript 能保证的是"同一份代码里类型一致",可保证不了"不同文件里手写的类型定义和数据库模型一致"。
一次字段改名的连锁反应大致是这样:
- 修改
schema.prisma中Order.status枚举定义 - 执行迁移并重新生成 Prisma Client
- 手改 Zod 校验中的
status字段 - 手改 tRPC 路由中的输出类型
- 手改前端对应的类型定义和枚举常量
五步里只要漏掉任何一步,编译期大概率不报错,运行期才炸。这比编译错误可怕得多,因为线上反馈有延迟,用户已经遭殃了你才知道。
1.2 样板代码到底有多少:统计一个典型 CRUD 模块
后来我做了一次统计,更坚定了要写工具的决心。一个典型的"博客文章"资源模块,只算基础 CRUD,不加任何业务规则,需要手写哪些东西?
| 文件/位置 | 大致内容 | 手写行数 |
|---|---|---|
| Prisma model | Post模型的字段、关系、索引 | 30-50 行 |
| Zod schema | 创建、更新、查询参数的校验 | 80-120 行 |
| tRPC router | createPost、getPost、updatePost、deletePost、列表查询 | 150-200 行 |
| 前端类型 | 从后端类型手动推导或重新声明 | 40-60 行 |
| API 封装 | fetch/axios调用 + 错误处理 | 60-100 行 |
| React Query hooks | useQuery、useMutation封装 | 80-120 行 |
加起来一个模块 440-650 行,而且是最枯燥、最没技术含量、最容易抄错的那部分。我要说句实话:这种代码的价值低到几乎为零,但出错的成本高到离谱。字段多一个、少一个,类型不一致,空值处理漏了,都是这类手写样板的高发问题。
t3code 的第一个目标就是把这些"低价值、高错误率"的样板代码从日常开发里拿掉,让你把精力留给真正重要的业务逻辑。
2. t3code 的设计思路与核心取舍
写工具之前,我先把需求想清楚了。当时市面上一堆"低代码平台"和"可视化搭建",看着很美好,但你真用在生产项目里就知道:生成的东西一复杂就不可维护。所以 t3code 的定位从一开始就很克制——它不是可视化平台,不跟你抢业务逻辑,只是一个帮你把类型同步做好、把样板补齐的代码生成器。
2.1 为什么是 CLI 而不是 VSCode 插件
其实第一版我也考虑过做 VSCode 插件,那样"保存文件自动生成"体验更顺滑。但后来我放弃了,原因有三:
- CLI 可以进 CI。插件只能在本地用,可"模型改了代码没跟上"这个问题,最应该拦截的地方是代码评审和 CI 阶段。CLI 加一个
check命令,谁改了 Prisma schema 没跑生成,CI 直接红,这才是真正的防护。 - CLI 不挑编辑器。团队里有人用 VSCode,有人用 JetBrains,有人用 Neovim,插件方案会制造工具割裂。
- CLI 可以串流程。
prisma generate && t3code generate一条命令链解决所有生成问题,插件做不到这种组合能力。
所以最后我选择了纯 CLI 方案:npx t3code搞定一切。这也让工具天然具备脚本化的能力,后续接 CI、接打包钩子都很方便。
2.2 "一张表驱动一切":以数据模型为唯一事实来源
t3code 最核心的设计决策是:把 Prisma schema 当作唯一事实来源,其他一切代码都从这里推导出来。
为什么不从 tRPC router 反推?因为 router 里混着业务代码,解析起来不干净;为什么不搞一个独立的"数据字典"文件?因为多一个文件就多一份约束成本,开发者迟早会忘记维护。Prisma schema 本身就是团队的"数据契约",你本来就要维护它,拿它当源头最省事。
整个生成链路是这样的:
schema.prisma(唯一事实来源) ↓ 解析模型字段、枚举、关系 Zod schema 生成(输入校验、输出类型) ↓ tRPC router 模板生成(自动包含 CRUD 基础实现) ↓ 前端类型 + API 封装 + React Query hooks这条链路解决了一个核心问题:同一个实体的描述只有一份,其他全是推导结果。Post模型里的title: String生成了 Zod 的z.string(),生成了 tRPC 的输入类型,生成了前端 hook 的入参类型,天然一致,再无"手写同步"这回事。
2.3 生成策略:可覆盖、可保留、可手动接管
代码生成工具最怕什么?怕把你手写的代码覆盖掉。t3code 的生成策略是三档:
- 自动生成文件:文件顶部带
// @generated by t3code标记,再次生成时整体覆盖。这类文件包括 Zod schema、前端类型、API 封装,它们本来就该是纯推导结果,你不应该手改。 - 半自动模板文件:t3code 生成 tRPC router 的骨架,但用
// @t3code-preserved-start和// @t3code-preserved-end标记出"保留区"。保留区里的业务代码在重新生成时不会动,保留区外的模板部分会被刷新。这样既享受生成效率,又不牺牲业务灵活性。 - 手动接管:任何文件只要去掉顶部生成标记,t3code 就再也不碰它。适合那种已经被你重度改造、和模板差距太大的文件。
这个设计参考了代码生成领域常见的做法:生成器只负责"生成",交接给"使用"时要有清晰的边界。模板代码和手写代码混在一个文件里,靠保留区做隔离,是我试过的最稳定的方案。
3. 上手实操:从初始化到跑通一个 CRUD 模块
光聊设计理念没有用,真正好不好用得跑一遍才知道。下面我用一个具体的例子,完整演示怎么用 t3code 从零生成一个带 CRUD 的模块。
3.1 安装与初始化
t3code 可以直接通过 npx 使用,也可以装成项目依赖。我建议装成 devDependencies,保证团队里所有人版本一致,CI 环境也好复现:
pnpm add -D t3code然后执行初始化:
npx t3code init初始化向导会问你几个问题:项目路径、是否启用 React Query hooks 生成、包管理器类型,等等。初始化完成后,项目根目录生成一个t3code.config.ts:
import { defineConfig } from "t3code"; export default defineConfig({ project: ".", prismaSchema: "prisma/schema.prisma", output: { zod: "src/server/schemas", trpc: "src/server/routers", client: "src/lib/api", hooks: "src/hooks", }, conventions: { inputSuffix: "Input", outputSuffix: "Output", }, features: { reactQuery: true, swr: false, }, });注意一个容易被忽略的点:配置文件里所有路径都是相对于配置文件所在目录的,不是相对于你执行命令的目录。这个设计是我踩过坑之后特意确定的,因为 CLI 工具最容易出的问题就是"在子目录里跑命令,生成位置全错了"。后面我会在踩坑章节详细说。
3.2 第一个生成命令:跑通一个 Post 模块
假设prisma/schema.prisma里有这样一个模型:
model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }执行生成命令:
npx t3code generate默认情况下,t3code 会为每个模型生成一套完整的 CRUD 代码。生成的目录结构大致是:
src/ ├── server/ │ ├── schemas/ │ │ └── post.schema.ts │ └── routers/ │ └── post.router.ts ├── lib/ │ └── api/ │ └── post.ts └── hooks/ └── use-post.ts其中post.schema.ts长这样:
// @generated by t3code - 手动修改此文件将被覆盖 import { z } from "zod"; export const PostInput = z.object({ title: z.string().min(1), content: z.string(), published: z.boolean().default(false), authorId: z.string().min(1), }); export const PostOutput = z.object({ id: z.string(), title: z.string(), content: z.string(), published: z.boolean(), authorId: z.string(), createdAt: z.date(), updatedAt: z.date(), }); export type PostInput = z.infer<typeof PostInput>; export type PostOutput = z.infer<typeof PostOutput>;post.router.ts里的 CRUD 骨架大概是这样:
import { router, publicProcedure } from "../trpc"; import { PostInput } from "../schemas/post.schema"; export const postRouter = router({ create: publicProcedure .input(PostInput) .mutation(async ({ ctx, input }) => { // @t3code-preserved-start // 这里可以写自定义业务逻辑,比如权限校验、事件发布等 // @t3code-preserved-end return ctx.prisma.post.create({ data: input }); }), list: publicProcedure .query(async ({ ctx }) => { return ctx.prisma.post.findMany(); }), detail: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.post.findUnique({ where: { id: input.id } }); }), });可以看到,生成的文件覆盖了"创建、列表、详情"这三个最常用的操作。更新和删除默认没有生成,因为更新涉及字段级别的权限控制,删除涉及级联行为,这些跟业务耦合太深,模板化了反而不安全。需要的时候你在保留区里手写就好,或者把 router 文件头部的生成标记删掉,完全手动接管。
3.3 增量更新与 watch 模式
开发过程中最频繁的场景是:模型加了一个字段,或者改了一个字段名。跑一遍t3code generate能全量刷新,但每次都手动跑很烦,所以我加了watch模式:
npx t3code watch这个命令会监听prisma/schema.prisma的变化,每次保存自动增量生成。请注意,它不是简单地全量重跑,而是做了字段级别的 diff:只有新增或修改的字段对应的 Zod、类型、API 封装才会被重新生成,其他文件保持不变。这样 git diff 干净,评审时一眼就能看到改动影响范围。
举个例子,我把Post模型加上tags String[],保存schema.prisma的瞬间,watch 会自动帮我把PostInput和PostOutput里的tags: z.array(z.string())加上,同时src/lib/api/post.ts里的类型定义也跟着更新。整个过程不需要我碰任何一个手写文件,类型安全链条自动闭合。
4. 把 t3code 接入真实项目工作流
工具写出来只是第一步,让它融入团队日常开发流程才是关键。我实际推进的过程中发现,代码生成器最怕的不是功能不够,而是"没人记得跑"。所以需要把生成动作嵌进已有的工作流里,形成习惯,甚至形成强制约束。
4.1 与 CI/CD 结合:让类型同步变成强制约束
前面提到,"模型改了但代码没跟上"这类问题最好的拦截点其实是 CI。t3code 专门提供了一个check命令:它会重新生成一遍代码,然后和当前仓库里的文件做对比,如果有差异就返回非零退出码。
在package.json里加一个脚本:
{ "scripts": { "typecheck": "tsc --noEmit", "codegen:check": "t3code check" } }然后在 CI 流程里加一步:
- name: Check type-safe codegen run: pnpm codegen:check有了这一步,谁要是改了 Prisma schema 忘了跑 generate,CI 直接红,PR 根本合不进去。这比任何代码评审建议都管用,因为机器检查是零成本、零遗漏的,人审总会漏。我甚至见过团队把这个 check 和 lint 合并成一个 pre-push hook,本地就能挡住大部分问题。
4.2 与编辑器 Task 整合:保存即生成,无感使用
CI 解决的是"事后拦截",但本地开发里,字段改完马上自动生成,体验更顺。我不建议每个人手动开一个t3code watch终端,更好的方式是交给编辑器任务。
在 VSCode 里,.vscode/tasks.json可以这样配置:
{ "version": "2.0.0", "tasks": [ { "label": "t3code: watch", "type": "shell", "command": "npx t3code watch", "isBackground": true, "problemMatcher": [] } ] }配合.vscode/settings.json让它在打开项目时自动启动:
{ "task.autoDetect": "on" }这样团队里每个人 clone 完项目、打开 VSCode,watch 任务自动在后台跑起来,不需要任何人背命令。这套组合拳打下来,生成器才算真正"隐形"了——你感觉不到它的存在,但它的确在替你守着类型安全。
4.3 生成的代码到底要不要提交进仓库
这个问题团队里争论了很久,我的结论是:提交,且必须提交。
理由很简单:tRPC 路由、Zod schema 这些生成物直接参与运行时逻辑,如果只留在本地,那么 CI 类型检查、部署构建、坏境复现全部依赖"先跑一次生成"。但凡哪一步漏了,线上跑的就是和仓库里不一致的代码,排查起来非常痛苦。
生成物提交进仓库,配合上面的check命令,能保证仓库状态和生成器始终一致。这也是很多成熟代码生成工具(比如 Prisma Client 本身的生成产物)的做法。你只需要在 README 里写清楚:"改 schema 后请跑pnpm codegen并提交生成文件"即可。生成的文件不用能工去考究格式,反正都是机器产物,git 历史看起来丑一点也无所谓。
5. 踩坑实录:我在真实项目中遇到的六个问题
任何工具写出来都要经历真枪实弹的考验。下面是 t3code 在落地过程中遇到的最典型的六个问题,每一个都是血泪教训,写出来帮你避坑。
5.1 路径解析的坑:cwd 不等于配置文件目录
CLI 工具最常见的 bug 就是路径错乱。用户可能在packages/web目录下执行npx t3code generate,但配置文件在项目根目录,而 Prisma schema 可能又指向prisma/schema.prisma这个相对路径。
我第一版实现用的是process.cwd()来解析所有路径,立刻被队友喷了——只要你在子目录里跑命令,生成位置就全乱了。后来改成:所有相对路径都以配置文件所在目录为基准解析,命令行参数只能是绝对路径或相对配置文件的路径。顺手加了一条校验:如果检测到 cwd 和配置目录不一致,打印一行提示。这个改动之后,再没出现过"生成到哪去了"的疑惑。
5.2 Prisma Client 生成顺序:生成的代码引用了还不存在的 client
t3code 生成的 router 代码会有ctx.prisma依赖,而ctx.prisma来源于 Prisma Client。如果你刚 clone 完项目、还没跑过prisma generate就直接跑t3code generate,生成的 TypeScript 文件会因为找不到@prisma/client类型而报错。
解决方式是在项目脚本里把两条命令串起来:
{ "scripts": { "codegen": "prisma generate && t3code generate" } }这里有一个小细节:prisma generate必须在t3code generate之前跑,不能反过来,因为 t3code 解析 Prisma schema 时需要用到 Prisma 的某些内部类型定义(尤其是枚举类型)。顺序反了会出现莫名的解析错误,别问我怎么知道的。
5.3 Zod 枚举和 Prisma 枚举的类型一致性
Prisma 里的enum字段和 Zod 里的校验,看起来是一一对应的,但有个隐藏的坑:Prisma 生成的是 TS 的enum,Zod 的z.enum()接受的是只读字符串元组。两者形态不一致,导致你从 Prisma 枚举类型直接构造 Zod schema 时会遇到类型兼容问题。
t3code 的默认处理是把 Prisma 枚举转成as const的对象,再用z.nativeEnum()来兼容:
import { PostStatus } from "@prisma/client"; export const PostStatusSchema = z.nativeEnum(PostStatus);这比z.enum(["DRAFT", "PUBLISHED"])强在哪里?强在当你改 Prisma 枚举时,Zod 这里天然跟随,不需要手动维护字符串列表。如果你用z.enum手写,就等于在另一个地方复制了一份枚举,又回到了"两处同步"的老问题。
5.4 模板覆盖手写代码:保留区解析的边界问题
保留区机制好用,但也不是万无一失。最开始的版本我用正则匹配@t3code-preserved-start和@t3code-preserved-end来切分文件,结果被队友搞出过一个诡异 bug:业务代码里有人写了一句注释,里面碰巧包含了保留区标记文本,导致生成时保留区范围错乱、手写代码被覆盖。
后来我把保留区的识别改成了基于模板引擎的结构化注释块解析,只认单独一行且格式完全匹配的标记,并且生成前先做一次"手写代码被覆盖"检查:如果检测到某个文件有未标记的手写改动,就拒绝覆盖并输出 warning。这个策略虽然保守一点,但安全第一,宁可让你手动合并,也不能静默丢代码。
5.5 watch 模式在 monorepo 里的表现
如果你在 monorepo(pnpm workspace、turborepo 等)里跑t3code watch,第一反应可能是每个子包都开一个 watch。这样会有大问题:schema 文件通常只有一个,但多个 watch 实例会同时响应同一个文件的变更,可能互相覆盖生成产物。
我的建议是:watch 任务只在拥有 Prisma schema 的那个包启动,其他包的配置文件通过extends复用根配置。t3code 配置支持继承机制:
export default defineConfig({ extends: "../../t3code.config.ts", output: { zod: "src/server/schemas", trpc: "src/server/routers", }, });子包只覆盖output路径,其他设置继承根配置。这样 monorepo 下只有一处 watch,生成物分发到各包,干净利落。
5.6 命名风格自动转换:PascalCase 和 camelCase 的边界
数据库字段和 TypeScript 代码之间有个绕不过去的坎:Prisma 模型名是PascalCase,但变量名和文件名习惯用camelCase。t3code 做自动转换看似简单,真的写起来才发现边界情况多到头疼:
BlogPost→blogPost(标准转换,没问题)APIKey→apikey还是apiKey?(缩写词到底拆不拆)UserID→userId还是uid?(每个团队习惯不一样)
这个问题的解决方案不是技术性的,而是约定性的:t3code 默认只做大小写首字母转换,不猜缩写语义。同时暴露一个naming配置项,允许你自定义转换函数。毕竟命名这东西,工具不该替团队做决定,做错了反而让人难受。你只需要统一"模型名必须 PascalCase、生成变量名必须 camelCase"这一条基础约定就够了。
结尾说一点个人体会。t3code 写了半年,最大的收获不是"我有个工具能自动生成代码",而是想明白了一个道理:代码生成器的价值不在生成本身,而在它能强制维护"单一事实来源"。手写代码时,模型改字段、校验改规则、类型改声明,全是脑力负担,随时可能漏;有了生成器,这些同步变成了机器的任务,人只需要管业务。开发效率的提升不是一点点,而是根本上少了一类问题的烦恼。
如果你想试一下,我建议从小处着手:先拿一两个模型跑通生成,看看保留区的业务代码和生成模板之间的配合是不是符合你们团队的习惯,再决定要不要全面推开。工具的设计是我根据自身经验做的取舍,不一定全适合你的项目,但"自动同步 + 强制检查"这个流程思路,值得每个 T3 项目借鉴。