t3code:从Prisma Schema自动生成T3全栈CRUD代码

发布时间:2026/10/26 23:29:58
t3code:从Prisma Schema自动生成T3全栈CRUD代码 把第三个业务模块的手写CRUD代码推倒重写的时候我终于承认自己的问题不是写得慢是写得烦。明明就是 list、get、create、update、delete 这几板斧换张表名就要从头复制粘贴一遍还得小心翼翼改掉所有驼峰命名、类型导入和 tRPC 路径。做 T3 StackNext.js TypeScript tRPC Prisma Tailwind CSS全栈项目的人大概率都有这种体会这套技术栈上手体验确实舒服可当业务表多起来那堆模板代码就会不间断地消耗你的耐心。所以我花了几周时间写了这个叫 t3code 的开源小工具。它不是 ORM也不是 CMS而是一个面向 T3 项目的代码生成器你给它一份 Prisma Schema它帮你输出一整层可用的 tRPC Router、Zod 输入校验、React Query Hooks甚至生成简单的管理页面骨架。这篇文章把它的设计思路、落地步骤和踩过的坑都写出来如果你也在维护一个 T3 全栈工程或者本身对“代码生成器”这种工具感兴趣应该能从里面找到点能直接用上的东西。1. 纯手写T3模块三个月后我终于决定给自己做个代码生成器1.1 一条User资源究竟要做多少步手工操作先数一下在 T3 Stack 里新增一个资源的完整流程。假设业务上需要一个 User 模块你至少要动这些文件在schema.prisma里定义 User model手动同步一份 Zod Schema 用来做 tRPC 输入校验字段类型、必填可选、默认值都要和 Prisma 对齐新建一个 tRPC Router把create、get、list、update、delete五个 procedure 都写出来每个都还要包一层输入校验和错误处理在根 Router 里手动注册这个子路由去页面层写 React Query 的useQuery、useMutation再配一个invalidate让缓存刷新写表单页、详情页、列表页页面里再手写加载态、错误态、空态如果有权限控制还要在每个 procedure 里重复贴 middleware 判断。这已经算比较克制的项目了。如果模型里有 relation、多对多、枚举字段、软删除步骤还要翻倍。第一次做觉得新鲜第二次忍一忍第三次开始暴躁。真正让我决定写 t3code 的瞬间是有一次我在 Prisma Model 里把emailVerifiedAt从DateTime?改成了DateTime updatedAt结果忘了同步 tRPC 的 Zod Schema线上报错查了半小时最后发现只是校验层和数据库层不一致。这种问题根本不是手速能解决的是维护多份同步定义的必然代价。1.2 自动化真正的收益不在敲字速度而在“一致性”很多人一听代码生成器第一反应是“能省多少打字时间”。其实纯手打那点时间真不算什么一个 CRUD 模块撑死写一两个小时。代码生成器真正的价值是让你永远不需要记住那套一致性约定把“人肉保证同步”这件事变成“机器保证同步”。比如我在 t3code 里约定了一套规则Prisma model 字段名是什么Zod 字段名就是什么Prisma 必填字段对应z.string()/z.number()可选字段对应.optional()default(now())的字段不出现在 create 输入里updatedAt的字段压根不生成到任何输入里外键字段只在 create/update 里暴露标量authorId不暴露对象author。这套规则一旦写进生成器全项目所有模块都是同一副面孔。Review 代码的时候你不需要思考“这里为什么 nullable 没设nullish()”生成结果永远符合预期。人写代码会疲劳、会状态差、会漏边界生成器不会。1.3 为什么不直接用现成的ORM或脚手架市面上其实有不少能“从数据库结构生成代码”的东西。Prisma 官方有prisma-case-format、zod-prisma-types之类的生态工具Next.js 生态里也有各种初始化脚手架。但我试用过一圈之后发现一个尴尬点它们大多数只解决了“数据库 schema 到 TypeScript 类型”这一段并没有解决“类型到 tRPC Router 和 React Query Hooks”这一段。T3 项目真正重复劳动的重灾区是应用层不是模型层。我需要的是能一次性生成post.router.ts、usePost.ts、PostForm.tsx这类文件的工具而不是只生成一个PostCreateInputType。t3code 的定位就是填补这个空白从 Prisma Schema 作为唯一输入直接产出一整套能跑通前后端的胶水代码。2. 用Prisma Schema当“唯一事实来源”t3code的核心设计2.1 字段类型映射规则Prisma类型到Zod类型的对应t3code 最关键的设计决策是让prisma/schema.prisma成为整个生成流程的唯一事实来源。原因很简单Prisma Schema 已经是你的数据库结构定义它具有不可辩驳的权威性。与其再造一套 DSL不如直接解析 Prisma Schema把它的字段类型映射成 Zod 类型。最基础的映射关系我用一张表列在下面这是生成器的核心映射表也是使用者最先应该理解的部分Prisma 字段类型t3code 生成的 Zod 类型说明Stringz.string()默认不设最小长度避免业务误伤Intz.number().int()你几乎不会想要一个浮点数的 IntFloatz.number()Booleanz.boolean()DateTimez.coerce.date()关键在coerce后面踩坑小节细说Jsonz.unknown()JSON 字段结构不可预测交给业务层再校验Enumz.enum([...])直接从 Prisma enum 定义里提取所有值String?z.string().optional()可选字段统一.optional()String default(...)z.string().default(...)默认值尽量和数据库保持一致我特意强调DateTime用z.coerce.date()而不是z.date()。因为 tRPC 客户端传参时前端很可能直接发 ISO 字符串过来z.date()会直接拒绝但coerce会帮你把字符串转成 Date 对象。实测下来这能让接口层少很多无谓的报错。对于db.VarChar(255)这种修饰符我不会把它转成.max(255)因为数据库层面已经限制了长度应用层再重复限制容易导致明明写了 200 个字也存不进去的困惑。想限制长度的话可以在配置里给特定字段追加校验规则而不是让生成器自动读db属性。2.2 从model标记推导CRUD能力id、updatedAt与关系外键知道了字段类型还不够生成器还得知道哪些字段该出现在 create 输入哪些该在 update 时处理哪些要作为查询条件。我用 Prisma 自身的标记来判断id字段create 时不可传入除了少数用default(cuid())的场景get 和 update 时作为主键查询参数default(now())或default(dbgenerated(...))字段create 输入里直接省略由数据库生成updatedAt字段任何输入都不暴露Prisma 会在更新时自动维护unique字段额外生成一个getByEmail/getBySlug之类的查询 procedure但默认不公开需要配置开启relation字段不会生成对象本身作为输入而是从fields: [authorId]里提取对应的标量外键字段放进 create/update 输入。这里最容易被忽视的是 relation 的处理。新手很容易在生成器里把author这个关系字段也当成普通字段生成一个z.object({...})结果真正提交时 Prisma 根本不知道你要传什么。正确做法就是只暴露标量外键让调用方传authorId: cm5...。后面我会专门讲这个问题为什么是坑。2.3 t3code.config.ts 如何控制生成行为任何一个生成器如果只能“一把梭生成所有模型”那它在真实项目里没法用。不同团队的命名风格、模块边界、权限策略差异太大了。所以 t3code 提供了一个类型安全的配置文件t3code.config.ts核心配置项长这样import { defineConfig } from t3code; export default defineConfig({ schemaPath: ./prisma/schema.prisma, outputDir: ./src/server/generated, generateHooks: true, generatePages: false, models: [Post, User, Comment], includeRelations: true, fieldNaming: camelCase, tsxImportPath: ~/utils/api, useServerOnly: true, zodGenerator: { additionalRules: { title: (field) field.name.includes(title) field.type String ? [z.string().min(1).max(120)] : [] } } });每个配置项都有它的用途。models用来限定只生成指定模型避免一次性生成几百个文件把 git diff 搞得没法看。fieldNaming控制多词字段的转换风格团队里有人喜欢user_id有人喜欢userId统一在这里设置。tsxImportPath解决的是 monorepo 或者不同项目别名不一致的问题否则生成出来的 import 路径全是错的。这里要强调一下我的一个设计偏好配置项是“行为约束”不是“万能开关”。一上来堆一百个配置项只会让工具更难以维护。t3code 的方向是让 80% 的项目不需要任何额外配置只把少数团队级差异暴露出来。3. 从零开始把t3code应用到一个新鲜出炉的T3工程3.1 安装与初始化npx t3code init用 t3code 的第一步很简单在任意 T3 工程根目录运行npx t3code init这个命令会生成一个带默认值的t3code.config.ts并且自动检测你项目里的schema.prisma路径。如果检测到你是 pnpm 工程它会在 devDependencies 里写入t3code如果没有检测到它只生成配置不会强行改你的 package.json。我建议 init 之后先改两个配置models先填一个简单的模型测试比如[User]outputDir先设为./src/server/generated。不要一上来就全量生成尤其项目里模型已经很多的情况先在单个模型上验证结果再逐步放开。3.2 一个带关联关系的Prisma示例为了展示 t3code 的实际效果我用一个非常经典的两层模型结构做演示一个 User 多个 Postmodel User { id String id default(cuid()) email String unique name String? createdAt DateTime default(now()) updatedAt DateTime updatedAt posts Post[] } 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 }这个例子虽然小但已经把主键、唯一索引、可选字段、默认值、时间戳、外键关系、反向关系全包括了。拿它来验证生成器比拿空表好用得多。3.3 执行生成的完整链路Schema - Router - Hooks - 页面配置写好后运行npx t3code generatet3code 会读取 schema解析 model然后按目录结构输出文件。生成结果大致是这个样子src/server/generated/ ├── post.router.ts ├── post.schema.ts ├── user.router.ts ├── user.schema.ts └── hooks/ ├── usePost.ts └── useUser.ts拿 Post 模块举例post.schema.ts大概长这样import { z } from zod; export const postCreateSchema z.object({ title: z.string().min(1), content: z.string().optional(), published: z.boolean().default(false), authorId: z.string().cuid(), }); export const postUpdateSchema z.object({ title: z.string().min(1).optional(), content: z.string().nullish(), published: z.boolean().optional(), authorId: z.string().cuid().optional(), }); export const postListSchema z.object({ take: z.number().int().min(1).max(100).default(20), skip: z.number().int().default(0), cursor: z.string().cuid().optional(), });对应的post.router.tsimport { createTRPCRouter, publicProcedure } from ~/server/api/trpc; import type { PrismaClient } from prisma/client; import { postCreateSchema, postUpdateSchema, postListSchema } from ./post.schema; export const postRouter createTRPCRouter({ list: publicProcedure .input(postListSchema) .query(async ({ ctx }) { const results await ctx.db.post.findMany({ take: input.take, skip: input.skip, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: desc }, }); return results; }), get: publicProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ ctx }) ctx.db.post.findUnique({ where: { id: input.id } })), create: publicProcedure .input(postCreateSchema) .mutation(async ({ ctx }) ctx.db.post.create({ data: input })), update: publicProcedure .input(z.object({ id: z.string().cuid(), data: postUpdateSchema })) .mutation(async ({ ctx }) ctx.db.post.update({ where: { id: input.id }, data: input.data })), delete: publicProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx }) ctx.db.post.delete({ where: { id: input.id } })), });页面层的 hook 也一并生成好了hooks/usePost.tsimport { api } from ~/utils/api; export function usePostList() { const list api.post.list.useQuery({}); const utils api.useUtils(); const create api.post.create.useMutation({ onSuccess: () utils.post.list.invalidate(), }); const update api.post.update.useMutation({ onSuccess: () utils.post.list.invalidate(), }); const remove api.post.delete.useMutation({ onSuccess: () utils.post.list.invalidate(), }); return { list, create, update, remove }; }这些生成出来的代码完全是“T3 项目的语言”没有抽象封装没有奇怪的工厂函数任何人拿到都能直接看明白。我觉得这是代码生成器最重要的品质生成结果要像团队里最细心那个同事手写出来的。3.4 把生成结果挂进根Router文件生成后还需要手动做一步把子路由挂到根 Router 上。从src/server/api/root.ts里修改export const appRouter createTRPCRouter({ post: postRouter, user: userRouter, });如果你把generatePages设成 truet3code 还会生成src/pages/posts/index.tsx、src/pages/posts/new.tsx、src/pages/posts/[id].tsx三个基础页面。考虑到每种技术栈的页面写法差异极大这个开关默认关闭我更建议团队里由一个人先跑通一版页面样板剩下的人照着改。4. 真正跑起来才发现的五个“坑”以及t3code给出的解法4.1 可选外键和嵌套创建的输入校验如何合并这是我在第一次用自己生成的 Router 调 Post 创建接口时立刻炸掉的坑。postCreateSchema里写了authorId: z.string().cuid()看起来没问题。但有个业务场景是“从草稿状态创建帖子先不指定作者”于是我需要把authorId变成可选的。改法很直接postCreateSchema变成authorId: z.string().cuid().optional(),问题出在 Prisma 的create调用上。如果 input 里authorId是undefinedPrisma 不会报错但如果当用户传了nullPrisma 会直接抛Argument authorId must not be null。因为 tRPC 的输入缺省是undefined只要生成器严格处理可选为空的情况其实不太会踩到。真正隐蔽的是另一种写法data: { ...input, authorId: input.authorId ?? undefined }如果你生成器不够小心在create的 data 里直接展开 input当用户没有传 authorId 时一切正常一旦某个前端把空字符串传进来Zod 校验拦截住了空串但用户预期是什么这里需要在 schema 上用.optional()还是.nullish()做明确区分。最后的解法是t3code 生成 create 的 data 之前会做一次“抹空”处理把所有值为null的可选字段过滤成undefined再把对象交给 Prisma。这样既保持了 tRPC 输入层的宽容度又不让 Prisma 有机会把null写进非空字段。4.2 Date对象在tRPC输出和React Query缓存里的序列化第一次跑通生成代码后我在列表页把createdAt直接渲染出来页面上显示的不是时间而是一段奇怪的字符串。查了半天才发现 T3 Template 默认给 tRPC 加了superjson作为 transformer它能把 Date 对象序列化成 ISO 字符串再传回客户端React Query 拿到之后又会把它反序列化为 Date。听起来没问题但如果你在自定义 procedure 里显式返回一个对象而对象里的字段本身不是 Prisma 返回的 Date而是你从别的地方塞进去的字符串类型系统就会悄悄骗了你TypeScript 认为它是 Date运行时它是 string。等到页面调date.toISOString()就炸。t3code 的应对策略是在生成的 Zod Schema 里统一用z.coerce.date()并且在生成的 Router 代码里不直接 return 掉 Prisma 对象而是通过一个可配置的serialize钩子交给团队自己决定。默认情况下我使用 superjson 的标准行为不做特殊处理但强烈建议所有页面层渲染时间时用Intl.DateTimeFormat不要直接依赖Date对象方法。4.3 多词模型名和字段名的命名转换我的项目里有一个模型叫PostCategory数据库表名被 Prismamap映射成了post_categories。生成器如果不做统一命名转换会生成postcategory.router.ts这种东西导入的时候既不好看也容易冲突。t3code 内部维护了一套完整的命名映射模型名PostCategory- 变量名postCategory文件名为post-category.router.ts字段名createdAt- 配置里如果设置fieldNaming: snake_case则生成created_attRPC 根路由 key 统一使用 camelCase和 React Query 的api.postCategory.list保持一致。这里最容易出错的是外键字段。比如模型里写authorIdPrisma 在数据库里的实际列名可能是author_id。生成器必须追踪relation(fields: [authorId])而不是靠猜。我在第一版实现里正则切分下划线结果遇到一个字段叫userIdNo差点没把命名搞炸。后来干脆用了一个成熟的 camelCase 转换库所有转换规则收敛在一个文件里再也没出过玄学命名。4.4 生成器自己的版本管理Prisma枚举和Client导入路径Prisma 每个大版本都会调整生成的 client 导入方式。从 Prisma 5 到 Prisma 6prisma/client依然能用但很多人开始用新的prisma-clientgenerator默认输出到自定义目录。这直接影响 t3code 生成代码里的导入路径// 旧版预期 import { PrismaClient } from prisma/client; // 新版预期假设输出目录是 ./src/generated/prisma import { PrismaClient } from ~/generated/prisma/client;我在 t3code 配置里加了prismaClientPath这一项让使用者显式声明 client 的导入地址。枚举类型的处理也类似如果 Schema 里定义了 enum生成代码时会生成独立的enums.ts所有的z.enum([...])会引用这个文件避免每个 router 文件里重复粘贴一组枚举字符串数组。这样当你新增一个枚举值只要重新生成一次所有校验逻辑自动更新。4.5 不是所有东西都该“生成”用注释标记跳过区域代码生成器的边界要非常清醒有些东西确实适合生成有些东西生成了反而碍事。比如一个列表接口可能需要非常复杂的过滤排序逻辑生成器硬要生成一个固定的findMany就是画蛇添足。t3code 支持在 Prisma Schema 里通过注释标记跳过model SecretNote { /// t3code.ignore id String id default(cuid()) encrypted String }被标记的模型不会生成任何文件。另外对于单个字段你也能用/// t3code.input: omit或/// t3code.input: readonly来控制是否出现在创建或更新输入里。这个能力我越用越觉得重要它把你从“生成器能这么做吗”的焦虑里解放出来生成满足 80% 场景剩下 20% 用注释显式声明例外而不是改配置或者手工改代码。5. 如果你也想写一个代码生成器我从t3code里总结的经验5.1 为什么用AST而不是字符串替换去改代码t3code 第一版也用正则做文件改写当时觉得“生成代码而已何必上编译分析”。结果遇到格式变化就出 Bug不同人的代码缩进、引号风格、最后一行有没有分号都会让正则匹配失效。后来我把生成器的核心从“替换文本”改成“操作 AST”用 TypeScript 官方的 compiler API 和 ts-morph 来读取、变换、输出代码。好处是不再关心原文件里到底有没有分号、用的是单引号还是双引号可以精准定位 class 的位置、import 语句的位置然后插入新代码生成代码前能自动格式化输出结果和 Prettier 一致。代价是学习曲线变陡ts-morph 文档不太全很多 API 要靠试。但这是值得的因为代码生成器最容易翻车的地方就在文本处理。我花在 AST 上的调试时间比写生成逻辑本身还多。5.2 生成代码的可读性大于一切别搞抽象封装我在做 t3code 最初几版的时候犯过错为了让生成的代码“干净”把很多逻辑抽成了共享的 base 类和工厂函数。结果生成出来的文件确实很短但读代码的人根本不知道这些 base 类做了什么调试的时候要跨好几个文件跳来跳去。后来我意识到对代码生成器而言生成结果应该“幼稚”把该写的字段、该导入的类型、该写的调用全部平铺展开宁可多写几行也不要为了 DRY 引入一层抽象。因为生成器的产出并不是给人反复维护的它需要的是“一眼看过去就懂”。举个例子与其生成usePaginatedQuery({ model: post })不如直接生成一份完整的usePostList函数。代码是重复了一点但每个文件的独立性和可读性都得到了保证。5.3 预留逃生舱门生成头部注释标明手动修改会被覆盖无论生成器有多么好用总会有需要手改生成结果的时候。t3code 会在每个生成文件的头部自动写入一行注释// generated by t3code - manual edits will be overwritten这行注释有双重作用。第一git diff 的时候一眼就能看出哪些文件是机器生成的review 时不用逐行检查第二万一有人忘了改配置而直接手改生成文件下次运行 generate 时他会发现自己改的东西丢了从而知道应该去改配置或 Prisma Schema。我建议任何写代码生成器的人都保留这个习惯最好再加一个// t3code-managed标记让 CI 脚本能扫描出那些“被手动修改过的生成文件”并给出警告。逃生舱门是给特殊情况用的但不能让紧急改法变成常态。5.4 生成器本身的快照测试和下一步规划代码生成器有一个特别让人头疼的问题你修了一个 Bug改了一行转换逻辑结果可能会悄悄影响几十个模型的生成结果。我现在的做法是维护一组“金样本”模型放进 fixture 目录每次改动 t3code 后跑一遍快照测试把生成结果全部输出然后和上一次的快照做 diff。只要 diff 里出现非预期的变化就能立刻定位。下一步我准备给 t3code 加一个diff命令让它能和 git 结合执行npx t3code generate --dry-run时不实际写入文件而是输出“哪些文件会新增、哪些会更新、哪些会删除”。这样在 CI 里也可以跑一次把生成结果是否与仓库一致作为检查项从源头上杜绝“生成器和手改代码脱节”的问题。如果你也想做类似方向的工具我的建议是先把“一小块最痛的流程”做通再慢慢扩展。t3code 最初只生成 tRPC Router连 Zod 都没有。后来发现 Router 里的 input 还要手动写才加上了 schema 生成再后来发现页面层重复又加了 hooks。功能迭代是被真实需求推着走的而不是一开始就设计一个全能怪物。最后分享一个小技巧升级 t3code 的时候不要只看 release note直接在你的项目里跑一次 dry-run然后把 diff 认认真真看一遍。机器生成的代码往往比你的记忆更诚实。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询