t3code:解决T3 Stack样板代码困局的增量代码生成器
第一次听到 t3code 这个项目名,是在一个全栈 TypeScript 交流群里。有人问:T3 Stack(TypeScript + Tailwind + tRPC)写起来确实爽,但每加一个数据模型,就要手动补 zod schema、tRPC router、服务端 handler、客户端 hook,这套样板代码什么时候能自动生成?我当时回了一句:等有人把脚手架做到“增量”这个粒度。后来我自己动手写了 t3code,就是为了解决这个痛点。它不是 create-t3-app 那种初始化工具,而是一个面向已有 T3 项目的代码生成器——你给它一个数据模型,它帮你把从数据库到前端 hooks 的整条类型安全链路补齐。这篇文章就把 t3code 的完整设计思路、实现细节、踩坑记录和团队落地方式都摊开讲。如果你正在用或者准备用 T3 全家桶,这篇文章应该能帮你省下不少重复劳动。
1. t3code 到底解决什么问题:T3 Stack 的样板代码困局
1.1 T3 Stack 的类型安全是双刃剑
T3 Stack 最吸引人的一点,是它把类型安全从数据库一路贯穿到浏览器端。Prisma 定义模型 → tRPC 自动推断输入输出类型 → React Query 在客户端拿到完全一致的 TypeScript 类型。写起来确实有种“全链路都是自己人”的踏实感。
但这份踏实是有代价的。代价就是每新增一个业务实体,你需要手动维护至少四层代码的同步:
- Prisma schema 里的模型定义
- zod schema(用于 tRPC 输入校验)
- tRPC router(包括鉴权、查询、变更逻辑)
- 客户端 hooks(useQuery / useMutation 的封装)
这还只是最基础的部分。真到了业务里,你可能还需要为分页参数、排序字段、过滤条件各写一套类型,并且确保它们和 Prisma 生成的类型完全对齐。一旦某层漏改一个字段,TypeScript 编译器在 dev 阶段通常不会报错(因为很多地方用了宽松类型或 any),等到线上请求跑起来,才开始抛运行时错误。这就是典型的安全感溢价:类型系统本来是为了提前暴露问题,但维护多层同步代码本身又成了新的问题来源。
1.2 create-t3-app 解决不了的“中途增量”问题
很多人会下意识反问:不是有 create-t3-app 吗?注意,create-t3-app 是一个初始化工具,它解决的是“从零到一”的问题。项目刚创建那一刻,它确实帮你搭好了基础目录、数据库连接和示例 router。但项目进行到第三周、第六个月,业务模型已经增加到十几个时,create-t3-app 帮不上任何忙。
我统计过自己一个中等规模项目里,手动新增一个Post模型平均要花多少时间。清单如下:
- 在
schema.prisma里定义模型,执行prisma migrate和prisma generate - 新建
src/server/api/routers/post.ts,手写createTRPCRouter结构 - 为每个入参手写 zod schema,尤其是
update接口要处理“部分字段更新”的类型 - 在根
appRouter里手动 import 并 merge 新 router - 在
src/hooks或组件目录里封装 React Query hooks - 确认所有页面的调用路径都被覆盖
这一套流程熟练的话也要 20 到 30 分钟,不熟练的能磨蹭一晚上。更麻烦的是,这个过程极容易遗漏第 4 步——router 忘了注册。忘了的结果不是编译报错,而是运行时一行 “No router found” 提示,排查起来很崩溃。
1.3 t3code 的定位:约定优于配置的增量生成器
所以 t3code 的定位从一开始就非常清楚:它不是初始化工具,而是增量生成器。它不做 create-t3-app 已经做得很好的事情,只负责解决“项目中间态”的重复劳动。核心思路是约定优于配置——按既定目录规则、命名规则、router 注册规则,把样板代码自动补齐。
| 对比维度 | create-t3-app | t3code |
|---|---|---|
| 使用时机 | 项目初始化 | 项目全生命周期 |
| 核心动作 | 创建工程模板 | 增量生成/注册/校验 |
| 是否覆盖已有代码 | 否 | 是(安全合并) |
| 是否需要幂等 | 不适用 | 必须幂等 |
| 解决的痛点 | 冷启动配置繁琐 | 新增模型需要手写全套链路 |
一句话概况:create-t3-app 让你能跑起来,t3code 让你在持续加功能时不掉进样板代码的泥潭。
2. 技术栈选型与整体设计:为什么我用 Node 脚本而不是插件框架
2.1 先定边界:生成器只在开发时运行
做这类工具,第一步不是选模板引擎,而是划边界。t3code 明确不做运行时的事情:它不参与编译,不生成任何在浏览器或服务端进程里执行的依赖,全部代码只在你开发时跑一次,输出普通文本文件。
这个定位直接影响技术选型。我见过有人把代码生成器做成 VS Code 插件,有人做成 web 界面,还有人做成大型配置平台。都有价值,但对 t3code 这个体量来说太重了。CLI 工具是最合适的形态:安装简单、可脚本化、便于 CI 集成、没有 GUI 状态残留。你把它挂在 package.json scripts 里,就是工作流的一部分。
2.2 模板引擎的选择:EJS 还是手写拼接
生成代码本质上就是“数据 + 模板”。我一开始也想省依赖,直接用字符串拼接。比如这样:
const routerTemplate = `import { createTRPCRouter } from "${apiTrpcPath}"; import { ${name}Router } from "./${name}";`;写三五个模板还能忍,一旦生成逻辑复杂起来——条件判断、循环遍历字段、处理可选类型——字符串拼接就变成一团乱麻。尤其是字段列表的循环渲染,手写map().join()虽然能工作,但可读性极差,模板一长你就很难发现哪一行漏了缩进。
后来我换成 EJS。理由很务实:
- 支持
<% %>控制流和<%= %>输出,逻辑和文本分离 - 语法足够简单,团队任何人半天能上手
- 单文件模板,天然支持复杂的嵌套结构
对比下来:
| 方案 | 条件逻辑 | 循环渲染 | 可读性 | 维护成本 |
|---|---|---|---|---|
| 字符串拼接 | 手动 if/ternary | map().join() | 差 | 高 |
| EJS | 原生语法 | 原生 | 好 | 低 |
| Handlebars | 需 helper | 原生 | 较好 | 中 |
最终选了 EJS。文件扩展名.ejs,配合ejs.renderFile使用,代码量小,逻辑集中。
2.3 目录结构与命令设计
t3code 的项目结构按命令拆分,每个命令只做一件事:
t3code/ ├── bin/ │ └── t3code.ts # CLI 入口,命令分发 ├── src/ │ ├── commands/ │ │ ├── init.ts # 初始化配置文件 │ │ ├── gen.ts # 核心:代码生成 │ │ └── check.ts # 校验生成结果是否与模板一致 │ ├── generators/ │ │ ├── zodGenerator.ts # 生成 zod schema │ │ ├── routerGenerator.ts # 生成 tRPC router │ │ ├── hookGenerator.ts # 生成 React Query hooks │ │ └── registry.ts # 路由器注册、文件合并 │ ├── parsers/ │ │ ├── schemaParser.ts # 解析 prisma schema │ │ └── typeMapper.ts # Prisma 类型到 zod 类型映射 │ └── utils/ │ └── fileUtils.ts # 文件安全合并、幂等处理 ├── templates/ # EJS 模板目录 │ ├── zod.ejs │ ├── router.ejs │ └── hook.ejs └── t3code.config.ts # 项目自定义配置命令只有三个,原则也三个:init只生成配置,gen只做增量,check只读不改。任何复杂的交互式流程、可视化编辑,一律不做,保持工具足够小、足够专注。
2.4 解析 Prisma Schema 的策略:正则匹配加约定解析
代码生成器最忌讳的就是对上游工具的内部结构做过度依赖。Prisma 官方有 AST(抽象语法树)解析能力,但各版本之间接口变化很大,直接依赖 AST 会把 t3code 锁死在特定 Prisma 版本上。
所以 schemaParser 走的是“正则匹配 + 约定解析”的路线。核心逻辑如下:
- 用正则定位
model 模型名 { ... }整体块 - 在这个块内部,逐行提取字段:字段名、类型、可选标记(
?)、数组标记([]) - 过滤掉
@@开头的块级配置 - 把结果塞进一个
ModelDefinition结构体
这段解析逻辑用代码看更清楚:
export function parseModels(schema: string): ModelDefinition[] { const models: ModelDefinition[] = []; const modelPattern = /model\s+(\w+)\s*\{([^}]*)\}/g; const fieldPattern = /^\s*(\w+)\s+([\w\[\]?]+)\s*(?:@\w+)?.*$/gm; let match: RegExpExecArray | null; while ((match = modelPattern.exec(schema)) !== null) { const name = match[1]; const body = match[2]; const fields: FieldDefinition[] = []; let fieldMatch: RegExpExecArray | null; while ((fieldMatch = fieldPattern.exec(body)) !== null) { if (fieldMatch[1].startsWith('@@')) { continue; } const type = fieldMatch[2].replace('?', ''); fields.push({ name: fieldMatch[1], type, optional: fieldMatch[2].includes('?'), isArray: fieldMatch[2].includes('[]'), }); } models.push({ name, fields }); } return models; }这种方案对 95% 的常规模型完全够用。唯一处理不了的是特别复杂的嵌套关系——比如自关联、复合主键、视图模型。我的处理策略是:生成器只处理标准 CRUD 链路,遇到关系字段直接跳过或只生成一个占位注释,提醒开发者手动补逻辑。约定好边界,工具才可靠。
3. 核心功能落地:一条命令生成完整 CRUD 链路
3.1 输入与产物清单
t3code 的使用体验被刻意设计得非常简单。你在项目根目录执行:
npx t3code gen Post它会做四件事:
| 序号 | 产物 | 目标路径 | 说明 |
|---|---|---|---|
| 1 | zod schema | src/server/schemas/post.zod.ts | 入参校验,包含 create/update 两种形态 |
| 2 | tRPC router | src/server/api/routers/post.ts | findMany / findUnique / create / update / delete 五个标准接口 |
| 3 | React Query hooks | src/hooks/usePost.ts | usePostList、usePostDetail、useCreatePost、useUpdatePost、useDeletePost |
| 4 | router 注册 | src/server/api/root.ts | 自动 import + merge 新 router |
注意gen命令还支持多个模型一起生成,比如npx t3code gen Post Comment Tag,内部会按依赖顺序逐个处理并做统一的注册更新。这一步对真实项目中“一次加三四个表”的场景很实用,不用逐个敲命令。
3.2 从模型到 zod 校验:类型映射规则
zod schema 生成的核心是一张类型映射表。Prisma 中的标量类型和 zod 校验器之间并不是一一对等,需要精心设计:
const typeMap: Record<string, string> = { String: 'z.string()', Int: 'z.number().int()', Float: 'z.number()', Boolean: 'z.boolean()', DateTime: 'z.date()', Json: 'z.unknown()', };这里最值得说两句的是Json类型。Prisma 的Json字段在数据库里可以是对象、数组、字符串,甚至null,运行时你根本不知道里面装了什么。早期版本我生成z.object({}).passthrough(),效果不好,因为调用方传什么乱七八糟的结构都校验不出问题。后来统一改成z.unknown(),至少在类型层面逼着你显式处理它。
可选字段的处理同样关键。Prisma 的?既可能代表“数据库可空”,也可能代表“更新接口中不需要传”,语义不对等。所以 create 和 update 用两套生成策略:
create接口:数据库必填字段要求必传,可空字段做成.optional()update接口:所有字段都包一层.optional(),但额外加一个.refine()校验禁止空对象提交
这样生成出来的 schema 业务语义是清晰的,不会出现“创建一个对象却一个字段都不允许填”的尴尬。
3.3 router 与 handler 的生成逻辑
router 模板里最核心的部分是五个标准接口的结构。由于 tRPC 的 input 直接接 zod schema,代码会非常规整:
import { createTRPCRouter, publicProcedure, protectedProcedure } from "@/server/api/trpc"; import { postCreateSchema, postUpdateSchema } from "@/server/schemas/post.zod"; import { prisma } from "@/server/db"; export const postRouter = createTRPCRouter({ list: publicProcedure .input(postListSchema) .query(async ({ input }) => { const { cursor, limit, orderBy } = input; return prisma.post.findMany({ take: limit, skip: cursor ? 1 : 0, cursor: cursor ? { id: cursor } : undefined, orderBy, }); }), detail: publicProcedure .input(postDetailSchema) .query(async ({ input }) => { return prisma.post.findUnique({ where: { id: input.id } }); }), create: protectedProcedure .input(postCreateSchema) .mutation(async ({ input, ctx }) => { return prisma.post.create({ data: { ...input, authorId: ctx.session.user.id }, }); }), update: protectedProcedure .input(postUpdateSchema) .mutation(async ({ input }) => { const { id, ...data } = input; return prisma.post.update({ where: { id }, data }); }), delete: protectedProcedure .input(postDetailSchema) .mutation(async ({ input }) => { return prisma.post.delete({ where: { id: input.id } }); }), });注意这里有两个设计细节。第一个:list接口用 publicProcedure 还是 protectedProcedure,不是拍脑袋定的,而是读取t3code.config.ts里每个模型的访问策略配置。第二个:create接口强制注入ctx.session.user.id作为authorId,这依赖一个约定——每个模型定义里要有author关系字段。没有这个字段的模型走普通创建逻辑。约定可以缺省,但缺省时必须给出明确提示。
3.4 安全的文件合并:幂等插入与标记注释
生成器的难点不在“生成全新文件”,而在“把内容安全地并进已有文件”。最典型的场景就是根root.ts里的 router 注册。如果每次执行都清空重写,你的手工改动就全丢了;如果用简单字符串替换,又会重复插入。
t3code 的做法是标记注释 + 幂等策略。生成的 router 文件顶部统一带一行溯源注释:
// @t3code-generated — do not edit manually在合并root.ts时,先检查目标文件里是否已有@t3code-generated区块,没有才插入。插入位置固定为// t3code: router-insertion-point标记之后。重复执行同一命令,第二次会直接跳过,输出一句“已存在,跳过注册”。
文件合并的核心逻辑如下:
export function insertRouterImport(rootContent: string, importStatement: string): string { const marker = '// t3code: router-insertion-point'; if (!rootContent.includes(importStatement)) { return rootContent.replace(marker, `${importStatement}\n${marker}`); } return rootContent; }这套逻辑配合的另一个保障是--dry-run参数。执行npx t3code gen Post --dry-run时,工具只打印将要写哪些文件、做哪些修改,不真正落盘。我先跑 dry-run,再用git diff看一眼改动,没问题再真正执行。手改的东西丢了,git 还能捞回来。
3.5 为什么必须强调幂等
幂等不是技术洁癖,是保证生成工具能进入工作流的底线。如果一条命令执行两次会产生两套重复代码,你就永远不敢把它放到自动化流程里。t3code 从第一版就把幂等作为硬性测试用例:同一输入执行两次,第二次必须零 diff。
这里的收益不只是“命令安全”,更重要的是一旦你有了幂等能力,就可以把生成动作挂到 pre-commit hook 上。每次提交前自动跑一遍,发现模型定义和生成代码不一致就自动修复。后面第 5 章细说,这里先记住一个结论:没有幂等,就别谈自动化。
4. 实测踩坑:类型推断失败与重复注入的排查全链路
4.1 事故 A:生成的 router 一直报“找不到类型”
第一个比较严重的坑,出现在 t3code 早期。我在一个分支上给模型加了一个Tag关联字段,跑完prisma migrate后直接用 t3code 重新生成对应 router,TypeScript 编译直接报了一串错误:
Property 'tags' does not exist on type 'GetResult<Post, ...>'第一反应是 t3code 生成的代码有 bug。但我打开生成的文件逐个对比,字段名、类型、大小写全部正确。然后我尝试自己手写一遍相同逻辑——照样报错。这时候才反应过来,问题根本不在代码生成,而在 Prisma Client 缓存。
4.2 根因:Prisma Client 没重新生成,缓存模板快照过期
排查链路是这样的:
- 确认
schema.prisma里确实有tags字段 - 确认
prisma/schema.prisma和数据库迁移状态一致 - 检查
node_modules/.prisma/client的生成时间——发现它压根没更新 - 手动执行
npx prisma generate,再跑tsc,错误消失
这个问题本质上是我的操作顺序问题。Prisma 的规则是:schema 变了,必须重新生成 client,编译器才知道新类型。这和 t3code 没有关系。但这里暴露了 t3code 一个真正的设计缺陷——它读取模型定义时用的是缓存快照,不是实时解析。
具体说,t3code 早期版本为了避免频繁解析 schema,会把模型定义缓存到node_modules/.cache/t3code/modelCache.json。当你在一个分支执行生成,切到另一个分支后,schema 文件已经变了,但缓存没失效,生成器拿到的还是旧分支上的字段列表。这个问题在团队协作时特别致命,因为每个人本地缓存的 schema 版本都不一样。
修复方案:生成器每次执行都直接从schema.prisma实时解析,不做任何模型级缓存。缓存只用于编译产物这类确实需要加速的内容,解析 Prisma schema 本身的耗时在毫秒级,根本没有缓存的必要。
4.3 事故 B:appRouter 里重复插入两个相同 router
第二个坑更隐蔽。我当时为了提高“兼容性”,在registry.ts里保留了两套插入策略:一套是标记注释插入,另一套是兜底的字符串替换。结果就是同一个 router 被插入两次,root.ts里出现两个一模一样的 import 和两个postRouter的 merge。
复现过程很典型:
- 第一次执行
t3code gen Post,标记注释策略正常插入 - 手动把某个区块的注释删掉(比如 IDE 自动格式化弄丢了一行)
- 第二次执行,标记注释策略找不到锚点,走兜底替换逻辑
- 兜底逻辑只判断 import 语句是否已存在,忽略了 merge 语句的存在性
- 结果 import 去重了,merge 却被插了第二遍
这个 bug 告诉我一个道理:任何兜底逻辑都会在某天成为新的 bug 来源。与其维护两套合并策略,不如把所有文件合并统一收敛到一套基于 AST 的插入逻辑上。后来我干脆把所有插入动作都改成了“先找标记注释,找不到就报错并提示人工处理”,彻底删掉了“智能兜底”。宁可报错,不要猜,猜错的后果远比报错严重。
4.4 防回归措施:快照测试与 diff 校验
踩过这两次坑之后,我把回归防线建了起来。
第一层是快照测试。t3code 自己维护了一个测试集,内置一个迷你示例项目。每次跑npm test,会用最新代码在示例项目里执行完整的生成流程,然后把所有生成结果和仓库里固定的快照文件做对比,任何输出差异都会导致测试失败。这样一来,模板、解析器、生成逻辑任何一处改动导致的行为变化都会被立刻发现。
第二层是 diff 校验。t3code 的check命令会做这样一件事:遍历所有已生成的@t3code-generated文件,用当前模板重新生成一遍,再和磁盘上实际存在的文件做比对。有差异就列出 diff 明细并以非零状态退出。这个命令天然就是为 CI 准备的。
作用我多说一句:它本质上是把“生成代码不可手改”这条约定变成可机器执行的检查。手改过的生成文件,下次check必亮红牌。
5. 团队化落地:把 t3code 嵌入日常工作流
5.1 package.json scripts 与提交钩子
工具只有进入工作流,才能持续产生价值。t3code 推荐的做法是把三条命令挂到 npm scripts 里:
{ "scripts": { "gen": "t3code gen", "gen:dry": "t3code gen --dry-run", "gen:check": "t3code check", "postinstall": "prisma generate && t3code check" } }注意postinstall这行。它保证任何人拉完代码、装完依赖,立刻知道仓库里有没有“生成代码与模板不一致”的问题。配合 lint-staged 的话,还可以把gen:check放进 pre-commit 钩子,每次提交前自动检查涉及的生成文件有没有被手工改过。
我用 lint-staged 的配置大致长这样:
{ "lint-staged": { "src/**/*.ts": ["eslint --fix", "prettier --write"], "prisma/schema.prisma": ["t3code gen --dry-run && t3code check"] } }意思是:修改了 schema 文件准备提交时,先 dry-run 展示会发生什么,再 check 确认没有问题,最后才允许提交。
5.2 自定义模板的扩展设计
不同团队对代码风格、目录结构、注释规范的要求差异很大。t3code 从设计上就允许你覆盖它默认的模板。
具体规则是:项目根目录建一个t3code/templates/文件夹,放入同名 EJS 文件即可覆盖内置模板。比如你希望 router 文件默认带@/lib/logger的日志记录,就往t3code/templates/router.ejs里加一段初始化逻辑。
命名约定和内置模板一一对应:zod.ejs、router.ejs、hook.ejs。配置层面则支持指定 schema 文件路径、router 目录、hooks 目录等基础参数。让团队可以只管自己的风格,工具负责把重复的骨架搭好。
5.3 CI 中强制执行的“不可手改”规范
前面说过生成代码顶部有// @t3code-generated标记,这行注释不是装饰,是给 CI 当证据用的。CI 里加一步:
npx t3code check如果某个开发者手改了生成文件,又没有重新生成,这条命令会直接失败。它可能影响发布流程。对团队来说,“生成代码不可手改”这个约定比任何 coding style 都更硬。
有人问手改生成文件和重新生成到底有什么区别——区别在于一致性。手改一次,下次模板一更新,手改的内容就消失了,你还得重新手工补回来。正确做法是:要么改模板,要么改配置,改完重新生成,让所有代码永远出自同一个源头。
5.4 从代码生成走向架构约束的设想
t3code 目前只约束“这一层代码怎么生成”,但我已经在规划它的下一阶段:把检查能力从代码生成扩展到架构边界。
比如可以增加一个t3code verify命令,在工作流里做更多约束:
- 禁止服务端代码 import
src/hooks/下任何内容(杜绝 client hook 泄漏到 server) - 禁止页面组件直接调用
prisma(必须走 tRPC router) - 禁止
prisma查询里出现大范围的select: true(强制指定字段)
这些规则本质上比简单的代码生成更有价值。生成器保证“新增代码符合架构”,验证器保证“所有人的代码始终符合架构”。一进一出,架构纪律就闭环了。
当然这些都是后续版本的事。目前 t3code 的核心能力已经足够在真实项目里用起来,踩过的坑也基本填平了。
最后聊点个人体会。最开始写 t3code 只是为了不让自己反复复制粘贴,后来做深了才意识到,这种工具真正发挥威力的场景不在个人项目,而在一个五到二十人的团队里。团队越大,手动同步样板代码的成本越高,出错的概率也越大。把“重复劳动”交给机器,把“模型设计”和“业务逻辑”留给工程师,这才是 T3 Stack 类型安全链条真正能兑现价值的方式。
如果让我给准备接入 t3code 的团队一条建议,就是:先跑--dry-run,再开git diff,确认生成的每一个文件你都看得懂,再放它进自动化流程。你亲手检查过的生成结果,比任何工具承诺都可靠。