
如果你手上有三五个基于 T3 Stack 的项目要同时维护大概率会碰到这样的场景某个服务里已经写好的 tRPC 路由要搬到另一个项目里结果里面的 Prisma 模型名、输入校验的 Zod schema、前端调用用的 hooks全都要手动改一遍。改的时候稍微不注意ctx.prisma.user.findMany这种类型推断就断了编译期不报错运行时报错。我就是在连续改了四个项目的 CRUD 代码之后决定写一个叫t3code的命令行工具把 T3 项目里这些高度重复的样板代码批量生成出来顺便把团队内部的命名约定固定下来。这篇内容我会把t3code从想法到实现的完整过程写清楚包括我为什么选择代码生成而不是复制粘贴、工具的命令和配置怎么设计、Prisma schema 怎么映射成 tRPC 路由和前端 hooks以及实际使用中的几个坑。如果你也在维护多个 T3 项目或者想自己写一个类似的代码生成器这篇应该能帮你少走不少弯路。1. 为什么需要 t3code手写 T3 样板代码的三个痛点有人可能会说CRUD 代码不就那几行吗复制粘贴改改不就行了。我第一次做内部的一个后台管理系统时也这么想但做了三个模块之后我发现复制粘贴的维护成本远比想象中高。1.1 类型安全链路在复制粘贴时最容易断裂T3 Stack 的核心卖点就是类型安全Prisma 模型、tRPC 路由输入输出、前端useQuery的返回值全链路类型推导。但复制粘贴代码的时候这条链路最脆弱。比如你从一个项目复制了一个update的 mutation 过来原项目里这个接口接收的是{ title: string, content?: string }新项目里表结构多了个coverUrl字段新手可能直接把这个字段拼进 Zod schema但忘了在 Prisma 的update数据对象里同步加上结果就是运行时不报错数据悄悄丢字段。这种问题在代码审查里非常难发现因为类型定义、路由逻辑、数据访问三层是分开的评审的人要对着三块代码逐一核对。我写t3code的初衷很简单让三块代码由同一个模板产出字段定义改了生成器重新跑一遍三处同步更新人工漏改的概率直接降为零。1.2 跨项目约定完全靠人肉记忆团队里如果只有一两个项目规范靠口口相传还能撑住。一旦项目多起来问题就来了A 项目的列表接口叫listB 项目叫getListC 项目叫page。前端同事换个项目写代码要先去翻路由文件确认函数名特别影响效率。t3code把命名约定写死在生成器里列表统一叫list详情统一叫byId创建是create更新是update删除是remove。所有项目生成的代码风格完全一致任何人拿到一个新项目不用看文档就能猜到接口名字。1.3 生成不是目的可维护才是设计t3code之前我考虑过两个方向一个是直接在运行时用 Prisma 的动态 CRUD 接口所有表共用一套路由实现很简单另一个就是现在这个方案为每个模型生成独立的 router 文件。前者的问题在于真实业务里的 CRUD 很少是纯 CRUD比如删除前要检查外键关联、列表要支持多条件过滤这些逻辑迟早要往每个路由里塞。共用路由后期必然变成一个大泥球。所以t3code选择了生成独立文件 后续人工微调的模式。生成器只负责把 90% 的样板代码产出来剩下 10% 的业务特殊逻辑你自己在生成的文件里改。这样既不限制业务扩展也避免了手写重复样板。2. t3code 的设计骨架从命令设计反推核心能力一个 CLI 工具好不好用第一眼看命令设计就知道。t3code的命令结构是我对着真实项目里的工作流反复打磨出来的。2.1 命令结构init / new / route / hook / sync整个工具暴露了五个核心命令npx t3code init npx t3code new --name user --model User npx t3code route --name user --fields id:String name:String email:String npx t3code hook --name user npx t3code syncinit负责初始化配置文件t3code.config.ts识别当前项目里的 Prisma schema 路径、tRPC router 目录、hooks 目录。new是最常用的命令读取 Prisma schema 中已有的模型为一个模型生成完整的 tRPC router包含 CRUD 全部 procedure和对应的前端 hooks。route和hook是单独生成某一层的命令适合你只需要路由不需要 hooks或者反向只需要 hooks 的场景。sync是全量同步模式遍历 schema 里所有模型生成所有缺失的 router 和 hooks并把已经生成的、没有手动改过的文件重新覆盖成最新模板。为什么要把new和sync分开我试过只做一个sync但团队使用中发现经常有开发者只改了某个模型定义、不想动其他已经写完业务逻辑的文件。全量覆盖会导致他们手写的特殊接口被冲掉。后来我加了文件指纹机制生成器在每个文件头部写入一行版本标记如果文件被人工改过指纹对不上sync就跳过这个文件。这是个很土但非常有效的方案保证了生成器不会成为代码毁灭器。2.2 配置文件 t3code.config.ts 设计配置文件长这样import { defineConfig } from t3code; export default defineConfig({ project: { schemaPath: prisma/schema.prisma, trpcPath: src/server/api/routers, hookPath: src/hooks/generated, componentPath: src/components/generated, }, generator: { prettier: true, withZod: true, protectedProcedure: true, includeSoftDelete: true, }, naming: { routerSuffix: Router, hookPrefix: use, listMethod: list, detailMethod: byId, }, });配置项里最有争议的是protectedProcedure。默认所有生成的路由都用protectedProcedure也就是要求用户登录才能访问。内部系统一般都这么干。如果你的是公开项目把它改成false就会生成publicProcedure。includeSoftDelete是我后来补的一个选项。实际项目里很少真的把数据删掉基本都是加个deletedAt字段标记软删除。开启这个选项后生成的removemutation 会从ctx.prisma.user.delete()变成ctx.prisma.user.update({ data: { deletedAt: new Date() } })同时列表查询自动排除deletedAt: { not: null }的记录。这个功能团队反馈非常好用。2.3 生成策略全量生成加增量覆盖t3code内部把本次要生成哪些文件算成一个计划计划由三个来源合并new命令指定的模型、sync遍历到的新模型、以及配置里声明的额外文件比如全局的context.ts初始化代码。生成计划确定后每个文件会先判断目标路径是否已存在、是否带指纹标记、内容是否和旧版一致然后决定是新建、覆盖还是跳过。这个策略我专门总结过一个规律开发者对生成工具最大的不信任不是生成的代码不好而是跑一次工具把改了半天的代码弄没了。所以哪怕逻辑复杂一点也要让用户明确感知到哪部分是安全的哪部分会被覆盖。目前sync跑完会在终端打印一张变更表列出每个文件是created、updated还是skipped一眼就能看出发生了什么。3. 核心实现把 Prisma Schema 翻译成 tRPC 路由和客户端 hooks设计好外壳之后最核心的是生成器本身。t3code本质上是一个元数据读取器 模板解析器 代码输出器的组合其中元数据读取器负责理解 Prisma schema模板解析器负责把元数据填进预置模板代码输出器负责格式化并写入文件。3.1 从 schema.prisma 读取模型元数据我不去重新发明一个 Prisma schema 解析器直接用 Prisma 官方提供的getDMMFAPI它能把schema.prisma解析成一个结构化的数据模型文档里面包含每个 model 的所有字段、类型、是否可选、是否唯一、关联关系等信息。一行调用就能拿到import { getDMMF } from prisma/internals; const dmmf await getDMMF({ datamodel: schemaContent }); for (const model of dmmf.datamodel.models) { // model.name - User // model.fields - 字段数组 }拿到模型字段后我要做几件事筛掉关系字段带relationFrom或relationTo的字段因为它们不应该直接出现在 CRUD 的输入 schema 里找出主键字段isId: true的字段用来生成byId查询的入参判断是否有deletedAt字段决定软删除分支。关系字段的过滤特别重要。比如Post模型里有个author字段关联User如果不过滤生成的创建接口会要求前端传一个嵌套的 author 对象这在大多数列表场景下根本不是我们想要的。实际用下来过滤动作给生成代码的正确性贡献了八成以上。3.2 路由生成规则与命名约定每个模型会生成一个 router 文件我以User模型为例说明命名规则文件名叫user.ts定义userRouter。五个 procedure 的名字固定为list、byId、create、update、remove。这样做的好处上文说了跨项目一致。生成的 router 文件骨架import { createTRPCRouter, protectedProcedure } from /server/api/trpc; import { userCreateSchema, userUpdateSchema, userWhereSchema } from /server/api/schemas/user; import { prisma } from /server/db; export const userRouter createTRPCRouter({ list: protectedProcedure .input(userWhereSchema) .query(async ({ input, ctx }) { const { page 1, pageSize 20, keyword, ...where } input; const [total, items] await Promise.all([ ctx.prisma.user.count({ where }), ctx.prisma.user.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy: { createdAt: desc }, }), ]); return { total, items, page, pageSize }; }), byId: protectedProcedure .input(userByIdSchema) .query(async ({ input, ctx }) { return ctx.prisma.user.findUnique({ where: { id: input.id } }); }), create: protectedProcedure .input(userCreateSchema) .mutation(async ({ input, ctx }) { return ctx.prisma.user.create({ data: input }); }), update: protectedProcedure .input(userUpdateSchema) .mutation(async ({ input, ctx }) { const { id, ...data } input; return ctx.prisma.user.update({ where: { id }, data }); }), remove: protectedProcedure .input(userByIdSchema) .mutation(async ({ input, ctx }) { if (ctx.prisma.user.softDelete) { return ctx.prisma.user.update({ where: { id: input.id }, data: { deletedAt: new Date() }, }); } return ctx.prisma.user.delete({ where: { id: input.id } }); }), });其中userCreateSchema、userUpdateSchema、userByIdSchema也是生成器产出的它们由字段类型映射而来Prisma 的String对应z.string()Int对应z.number().int()DateTime对应z.date()或z.coerce.date()可选字段套一层.optional()。schema 单独拆成一个文件是为了让前端也能 import 这份校验逻辑真正实现前后端共享一套输入定义。3.3 前端 hooks 和 react-query key 的封装前端 hooks 我选择了 tRPC 的 React Query 集成。每个模型生成一个useUser.ts文件里面导出useUsers、useUser、useCreateUser、useUpdateUser、useRemoveUser五个 hooks。其中查询 hooks 直接包装 router 上的 querymutation hooks 包装 mutation同时把 React Query 的invalidate逻辑预先配好。这是我最满意的一部分设计export function useCreateUser() { const utils trpc.useUtils(); return trpc.user.create.useMutation({ onSuccess: () { utils.user.list.invalidate(); utils.user.byId.invalidate(); }, }); }为什么要做成生成而不是让前端同事自己写useMutation因为真实项目里创建完用户之后要刷新列表、如果有详情页还要刷新详情这套失效逻辑是固定套路但每个人写的细节都不一样。有人只invalidate列表导致详情页数据过期。生成器直接把这个规律固化下来每个 mutation 成功后会自动让相关查询失效。useUsers的 hooks 我加了placeholderData: keepPreviousData做分页时切页不会白屏闪烁。这个默认配置看起来是小事但实际体验提升非常明显。3.4 模板引擎选型为什么选 EJS 而不是 Handlebars模板引擎我对比过 EJS、Handlebars、Nunjucks最后选了 EJS。原因有三个一是 EJS 的语法和 JavaScript 最接近写模板的人不需要学太多新东西。比如循环模型字段直接写% for (const field of fields) { %就行而 Handlebars 的 helper 语法需要额外定义函数。二是 EJS 支持在模板里直接调用自定义函数。我在模板里放了一个toPascalCase函数把slug这种命名转成Slug作为类型名这个在 Handlebars 里要注册 helper比较绕。三是我们团队本来就大量使用 TSEJS 在.ejs文件里写出来的代码几乎可以直接对照最终产物调试心智负担最小。模板目录长这样templates/ router.ts.ejs schema.ts.ejs hook.ts.ejs每个模板都有版本号注释开头是// t3code-version: 1.2.0这就是前面提到的文件指纹。生成器每次写入前检查这个版本号如果旧文件版本号能匹配就安全覆盖如果不匹配说明人工改过跳过。4. 跑一个真实例子十分钟生成一整套 User CRUD光讲设计可能还是会觉得虚我拿一个实际项目的完整操作过程来演示。一个全新初始化好的 T3 Stack 项目从零生成User模型的 CRUD。4.1 初始化和生成命令第一步先把工具装进来并且初始化npx t3code init运行之后根目录多了一个t3code.config.ts我在里面配置好schemaPath等路径。然后假设prisma/schema.prisma里已经有User模型model User { id String id default(cuid()) name String email String unique age Int? createdAt DateTime default(now()) updatedAt DateTime updatedAt }执行npx t3code new --name user --model User它会创建四类文件src/server/api/routers/user.tstRPC 路由src/server/api/schemas/user.tsZod 校验 schemasrc/hooks/generated/useUser.tsReact hookssrc/components/generated/UserTable.tsx可选的基础列表组件4.2 生成的代码长什么样上面已经展示了 router 的结构这里重点看 schema 文件因为大多数新人会忽略它import { z } from zod; export const userCreateSchema z.object({ id: z.string().cuid().optional(), name: z.string().min(1), email: z.string().email(), age: z.number().int().positive().optional(), }); export const userUpdateSchema userCreateSchema.partial().extend({ id: z.string().cuid(), }); export const userByIdSchema z.object({ id: z.string().cuid(), });注意到id字段我用.optional()因为创建时 Prisma 会自动生成 cuid前端不需要传。age是可选字段所以加.optional()同时因为是Int?还加了.int().positive()约束。这些约束都是根据 schema 里的类型标注自动推导的不是手写的。前端 hooks 里useUsers接受一个查询参数内部转给listprocedureexport type UserListParams { page?: number; pageSize?: number; keyword?: string; }; export function useUsers(params: UserListParams {}) { return trpc.user.list.useQuery(params, { placeholderData: keepPreviousData, }); }这里我特别处理了keyword参数如果配置里声明了某个字段为搜索字段生成器会把keyword加到list的输入里并在后端用contains做模糊查询。目前默认对name、title这类字符串主字段开启。4.3 如何验证生成结果是否正确生成完之后不要急着跑next dev我建议先跑两遍检查。第一遍是类型检查npx tsc --noEmit重点看有没有any泄漏或者ctx.session.user类型为空的报错第二遍是跑一次npx prisma generate确保 schema 和 Prisma Client 同步。我实际遇到次数最多的报错是Type string | undefined is not assignable to type string这通常出现在updatedAt这类字段的处理上。生成器默认把updatedAt当作自动维护字段会从创建和更新的输入里排除掉不应该出现在 schema 里。如果模板逻辑写错把它算进去了就会报这个错。所以生成器内部维护了一批自动字段黑名单id、createdAt、updatedAt、deletedAt这些字段默认不进入输入 schema。4.4 生成成功之后下一步当我确认生成的 CRUD 跑通之后一般会进入一个很常规的二次开发流程往update的 mutation 里加业务校验比如邮箱不能重复、更新用户时如果传了password字段要做哈希等。这些逻辑直接在生成的文件里改即可。一个重要的习惯是任何针对生成文件的二次修改都尽量通过重新生成再改动而不是直接手写来更新底层模板。我自己会先把金标准代码写好再把金标准代码抽成模板。这样下次遇到同样业务直接new一下 少量调整就完成了不用从零写。5. 踩过的坑命名冲突、可选参数和模板转义生成器写出来容易写稳很难。下面这几个坑我在真实使用中踩过有的还是生产环境才暴露出来的值得单独说。5.1 Prisma 模型名与 tRPC procedure 命名冲突第一个坑来自一个特别绕的场景。某个模型叫Case它有一个list字段。我的生成器给listprocedure 的入参加了一层whereschema同时又有一个模型字段叫where的话生成的z.object({ where: ... })就会内外层同名类型推导直接乱掉。更常见的变体是模型名首字母小写后和某个内置函数重名比如Order变成order倒没问题但如果模型名本身就叫List生成listRouter没问题生成listprocedure 就会跟list这个名字撞车。解决策略很朴素生成器在命名时检查目标名字跟 model 名、procedure 名是否冲突冲突就加后缀。我把这类规则写进了命名修复器所有命名都过一遍sanitizeName函数它会检查保留字列表、已有 router 列表和字段名列表。5.2 可选字段的 TypeScript 类型推断陷阱第二个坑是 Zod 的.optional()和 TypeScript 的undefined类型推断在 Prisma 输入类型上的表现不一致。举个例子age?: number的字段在 Zod 里用z.number().optional()校验但生成create的 data 时如果前端没传age这个字段在input对象里的值是undefined。理论上Prisma.UserCreateInput允许字段缺省但在严格 Equal 类型检查下{ age: undefined }和{ age: undefined }两个对象赋给 data 时可能报错。我在实际业务里发现只要使用z.input和z.output分开定义这个问题就好解决。生成 schema 时我会额外导出类型export type UserCreateInput z.inputtypeof userCreateSchema; export type UserCreateOutput z.outputtypeof userCreateSchema;然后在 router 里显式把input声明为z.inputtypeof userCreateSchema让 TypeScript 能正确推导出age是「可缺省」。这个细节看文档很容易忽略但它能让你少 debug 半小时。5.3 模板转义在 EJS 里输出 TypeScript 泛型EJS 的默认标签是% %而 TypeScript 的泛型大量使用和比如z.number().lt(100)或者ArrayPost。如果在模板里写了ArrayPostEJS 会尝试把Array当作标签开始解析然后抛语法错误。解决办法有两个。一是把泛型写法改成ArrayPost而不是Post[]这个写法本身是合法的但不直观二是用 EJS 的%转义技巧把写成lt;再在最终代码里反转非常麻烦。我最终采取的方案是模板内部约定输出代码里不直接写泛型尖括号而是用自定义函数的字符串输出。比如toQueryType(User)在模板里返回User[]这种带方括号的写法。同时我在模板文件开头做了规范注释提示所有模板编写者输出 TS 代码时一律用Array...或方括号语法替代字面泛型。这个约定在小团队里够用各位如果要推广到更多协作场景建议引入一个更健壮的转义方案。5.4 别忘了 createContext 里的 session 类型生成的 procedure 默认都是protectedProcedure它的内部实现会从ctx.session?.user取出当前登录用户。但很多 T3 项目的createContext里session的类型是Session | null从模板生成的代码里如果直接写ctx.session.user类型检查会报错。我吃过的亏是第一次生成时没有在模板里统一处理导致生成文件里散布了十来个ctx.session!.user既有非空断言又有运行时崩溃隐患。后来我把模板统一改成先校验再使用const userId ctx.session?.user?.id; if (!userId) throw new TRPCError({ code: UNAUTHORIZED });这段逻辑我直接生成进每个 protected procedure 的开头确保类型推导安全。对内部系统来说这个防空检查也避免了很多登录态过期导致的诡异报错。6. 后续可以怎么扩展t3code目前还在迭代我自己最想做的几个方向也写出来供想做类似工具的朋友参考。6.1 从 OpenAPI 生成外部 API 客户端团队里总有一些老系统只有 REST 接口没有类型定义。如果能传入一份 Swagger/OpenAPI 文档让t3code生成带类型安全的 API 客户端代码会比现在手动封装 fetch 好用很多。这个方向上我会优先做 Zod schema 的自动推导把OpenAPI的string/number/array转成对应的zod表达这样外部接口的参数和返回体在 T3 项目里也能走同一套校验链路。6.2 团队约定配置共享目前t3code.config.ts只能在本地项目配置我们团队有两个项目出现过配置漂移A 项目开启了软删除B 项目没开导致同样一个remove接口行为不一致。我想做一个远程配置能力把命名约定、字段黑名单、模板版本从远程拉取类似 ESLint 的可共享配置。这样init的时候直接指定一个配置包名就能让所有项目对齐。6.3 自定义模板市场最后是模板能力开放。每个团队的业务代码风格差别很大有的喜欢把 service 层抽出来有的直接在 router 里写逻辑。与其让t3code去适配所有风格不如允许开发者上传自己的模板包生成器只是元数据 模板渲染的执行引擎。这样工具的定位就从帮你生成 T3 默认代码变成了按你的团队规范生成代码。我在实际使用中体会最深的一点是代码生成工具的价值不在于自动写代码这一下而在于它能把你反复琢磨过的工程决策固化下来让团队里的人不用每次都重新讨论。如果你打算自己写类似的生成器我的建议是先把你要生成的目标代码做到足够好——把它当作金标准再把它抽成模板。这样生成器永远只是你的助手而不是替你做决定的人。