
凌晨两点我盯着屏幕上那条报错心里只剩一个念头再也不手写样板代码了。当时我正在维护一个基于 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 modelPost模型的字段、关系、索引30-50 行Zod schema创建、更新、查询参数的校验80-120 行tRPC routercreatePost、getPost、updatePost、deletePost、列表查询150-200 行前端类型从后端类型手动推导或重新声明40-60 行API 封装fetch/axios调用 错误处理60-100 行React Query hooksuseQuery、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.tsimport { 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.infertypeof PostInput; export type PostOutput z.infertypeof 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 忘了跑 generateCI 直接红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 完项目、打开 VSCodewatch 任务自动在后台跑起来不需要任何人背命令。这套组合拳打下来生成器才算真正隐形了——你感觉不到它的存在但它的确在替你守着类型安全。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 生成顺序生成的代码引用了还不存在的 clientt3code 生成的 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 的enumZod 的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 里的表现如果你在 monorepopnpm 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 项目借鉴。