t3code:一条命令生成Prisma、Zod、tRPC与前端Hooks

发布时间:2026/10/28 15:47:23
t3code:一条命令生成Prisma、Zod、tRPC与前端Hooks t3code这个项目最初是我在维护一个中大型全栈应用时被类型地狱反复折磨后决定动手解决的。简单说t3code是一个围绕TypeScript生态的自动化代码生成工具它把后端数据模型作为唯一事实来源一条命令同时生成Prisma模型、Zod校验、tRPC路由层和前端调用Hooks让前后端类型定义不再需要手工同步。它适合那些受够了改一个字段要动四处代码、又不想放弃端到端类型安全的全栈开发者也适合团队内部用来统一CRUD这类重复代码的生成规范。我更愿意把t3code理解成一个小型编译器你输入的是schema描述输出的是一整套可以被框架直接引用的代码文件。这篇文章会把整个项目的来龙去脉、架构决策、实际操作步骤和踩坑记录都展开聊一遍希望对正在做同类工具或准备引入代码生成工作流的同学有帮助。1. 项目起源从类型泥潭里爬出来的副产品1.1 后端接口一改前端就崩的老问题先说我自己遇到的实际场景。项目用的是Next.js Prisma tRPC这套组合初衷很简单tRPC的泛型推导理论上能实现端到端类型安全后端Router类型可以被前端直接引用中间不需要手写API文档和类型声明。听起来很美但用起来会发现一个尴尬的事实tRPC只能保证调用端的参数和返回值类型被推导出来它并不能帮你自动维护Prisma Schema、Zod校验规则、前端类型定义、mock数据类型之间的一致性。最经典的情况是这样的后端同事在Prisma的User模型里把email字段从必选改成了可选原本只需要动一行schema结果整个链路全塌了。Prisma重新编译没问题但以email为必选条件编写的Zod校验不会自动改tRPC router里基于旧类型写的select函数还在读email前端Person类型定义里email还是string而不是string | undefined万一哪个页面用email作为key运行时直接崩。最要命的是这些错误不是一次全部暴露的而是改一处处崩一处崩完你还要到处搜文件找是谁的类型没同步。我统计过一个迭代周期里花在这种事情上的时间一周至少有四五个小时浪费在对齐类型上。这种事情出现一次是意外出现几十次就该反思了。1.2 T3 Stack给了思路但还差最后一公里当时T3 Stack这套组合在社区里火TypeScript、Tailwind、tRPC、Next.js搭配得很顺。tRPC的核心卖点就是类型安全官方文档也强调InferRouter这类工具可以让前端直接获得后端路由类型。我试下来感觉它解决的是调用层面的贯通但生成层面——也就是从数据库模型到校验规则、到接口函数、到前端Hooks——仍然要靠手工维护。打个比方tRPC像是一个邮局给了你一个固定的通讯地址后续所有包裹都按这个地址送但是包裹内容物清单每次都得自己写写错了地址也救不了你。我需要的不是更好的投递协议而是能从一份订单自动生成完整货品清单的机器。这个机器就是t3code最初的原型。1.3 决定自己写一个schema驱动的代码生成器既然市面上找不到趁手的我就决定自己做一个。我的目标很明确用户只用一份schema描述数据模型比如用户、文章、订单工具自动生成Prisma的模型定义、Zod的校验器、tRPC的router、前端类型与Hooks生成的代码要可读、可改而不是一个黑盒支持增量更新用户手写的业务逻辑不能被覆盖所以t3code不是脚手架而是一个以schema为输入、以多文件为输出的代码生成器。它更像Babel或Prisma这种工具而不是create-react-app那种一次性生成项目结构的工具。2. 核心架构设计一条Schema打通前后端2.1 单一数据源为什么直接拿Prisma Schema当输入t3code最开始还真想过设计一套自己的DSL来描述模型后来被自己否了。原因是任何引入新DSL的操作都会增加学习成本降低采用率。Prisma Schema本身已经是很多全栈项目的模型事实来源再搞一套等于凭空多出一道转换工序。所以现在t3code直接把Prisma Schema作为唯一输入。它通过Prisma官方提供的getDMMF接口来解析schema文件拿到完整的模型信息字段名、类型、可选性、默认值、唯一约束、枚举值、关联关系。这些信息已经足够生成所有目标代码。单数据源原则具体体现在这几个文件的关系上schema.prisma是唯一需要人维护的文件生成文件统一放在.t3code或generated目录并打上标记注释任何情况下都不允许直接修改生成文件里标注generated的区域手动业务逻辑写在标注user-code的保留区块中增量生成时会被保留我后来意识到这个单数据源保留区块的设计是整个项目能否长期用下去的关键。没有这个约束生成的代码被改过一版之后就没法再自动更新了工具就废了。2.2 类型生成器从Prisma模型到Zod校验到前端类型这是t3code的核心模块本质上是一连串的映射规则。Prisma模型描述的是数据库结构Zod要的是运行时校验约束前端TypeScript要的是静态类型三者语言各不相同但信息同源。我举一个实际例子说明映射逻辑。假设schema里有这么一段model Post { id String id default(uuid()) title String body String? tags String[] }t3code生成的Zod校验器大致长这样export const PostSchema z.object({ id: z.string(), title: z.string().min(1), body: z.string().optional(), tags: z.array(z.string()).default([]) });生成的关键逻辑是String对应z.string()String?可选字段对应z.string().optional()String[]对应z.array(z.string())id字段不生成入参校验里的必填约束因为数据库自动生成unique字段通过z.string()保留唯一性语义给上层DB层兜底默认值出现在生成的前端类型里以避免undefined与null之争映射核心其实是对字段可选性的处理。Prisma里一个字段可选有三种轻微差异数据库层可空、输入层可选、操作层可缺省。我写第一版时天真地认为String?就等价于z.string().optional()后来发现很多字段其实是数据库必填但创建时不传的比如id和createdAt。所以生成器必须细分字段来源属性是default、id、updatedAt还是纯手工可选字段。这也是新上手做代码生成最容易摔的跟头。2.3 模板系统与插件机制让不同团队能定制自己的一点私货t3code虽然默认生成的代码已经能直接用但不同团队一定有自己的一套规范。有人希望在生成的文件头上加版权信息有人希望用自己封装过的trpc客户端还有人希望能自动生成权限装饰器。所以模板一定要可定制。模板选的EJS语法简单能满足循环、条件、嵌套。每个文件类型对应一个模板目录结构t3code-templates/ zod/ model.schema.ejs trpc/ router.index.ejs hooks/ useModel.ejs types/ model.type.ejs用户可以通过配置文件指定自定义模板目录也可以在初始化时把默认模板导出到项目里修改。插件机制参考了PostCSS的模型每个插件是一个函数接收生成上下文和文件列表可以在生成前修改schema解释结果也可以在生成后修改文件内容甚至新增文件。// t3code.config.ts import type { Plugin } from t3code/core; export const auditPlugin: Plugin { name: append-imports, beforeWrite(ctx) { ctx.files.push({ path: src/lib/audit.ts, content: export const audit (op: string) console.log([${new Date().toISOString()}] ${op}); }); } };这个插件API并不复杂但它让t3code从一个我写的工具变成了团队能扩展的平台。很多用户不一定写普通插件但会在模板层面做调整比如统一加日志、统一错误处理封装。模板可定制性的优先级我认为高于插件机制因为大部分需求靠改模板就能解决。3. 实操演示十分钟跑通全栈代码生成3.1 环境准备与初始化我用一个全新的Next.js项目来演示t3code的完整流程。前提条件Node18、npm或pnpm新版Next.js 15。先创建一个基础应用npx create-next-applatest t3-demo --ts --tailwind --eslint --app cd t3-demo接着安装t3code本身和它依赖的生态库npm install zod trpc/server trpc/client tanstack/react-query npm install -D prisma t3code/cli npx prisma initinit之后项目里会多出prisma/schema.prisma和.env两个文件。我们现在把schema文件稍微改一下加入业务模型3.2 定义一份会被反复使用的数据模型在prisma/schema.prisma里写下generator client { provider prisma-client-js } datasource db { provider sqlite url env(DATABASE_URL) } model User { id String id default(uuid()) email String unique name String? posts Post[] createdAt DateTime default(now()) } model Post { id String id default(uuid()) title String content String? authorId String author User relation(fields: [authorId], references: [id]) }然后执行npx prisma migrate dev --name init npx t3code initinit命令会生成一个t3code.config.ts文件并且询问你生成目录、默认模板、是否开启watch模式。我一般把生成目录设为src/generated这样IDE的tsconfig能自动覆盖。3.3 一条命令生成四个关键文件核心命令只有一条npx t3code generate它会读取schema在src/generated/下生成如下文件src/generated/ types.ts // 所有模型的前端类型定义 validators/User.ts // User的Zod校验器 validators/Post.ts // Post的Zod校验器 routers/user.ts // User的tRPC router routers/post.ts // Post的tRPC router hooks/useUser.ts // 前端Table接口查询的Hook hooks/usePost.ts生成的validators/User.ts内容大致如下import { z } from zod; export const UserSchema z.object({ id: z.string(), email: z.string().email(), name: z.string().optional(), createdAt: z.date() }); export type UserInput z.inputtypeof UserSchema; export type UserOutPut z.outputtypeof UserSchema;routers/user.ts则包含一个正交的CRUD实现import { router, publicProcedure } from ../trpc; import { prisma } from ../../lib/prisma; import { UserSchema } from ../validators/User; export const userRouter router({ list: publicProcedure.query(() prisma.user.findMany()), byId: publicProcedure.input(z.string()).query(({ input }) prisma.user.findUnique({ where: { id: input } })), create: publicProcedure.input(UserSchema.omit({ id: true, createdAt: true })).mutation(({ input }) prisma.user.create({ data: input }) ) // 保留区块 // t3code:user-custom-start // 在这里手写你的自定义方法 // t3code:user-custom-end });3.4 接入tRPC和UI层验证效果生成router之后把它挂到总路由上// src/server/trpc.ts import { initTRPC } from trpc/server; export const t initTRPC.create(); export const router t.router; export const publicProcedure t.procedure;// src/app/api/trpc/[trpc]/route.ts import { fetchRequestHandler } from trpc/server/adapters/fetch; import { appRouter } from /server/appRouter; export const GET (req: Request) fetchRequestHandler({ endpoint: /api/trpc, req, router: appRouter }); export const POST (req: Request) fetchRequestHandler({ endpoint: /api/trpc, req, router: appRouter });前端页面里直接用生成的Hookimport { useUser } from /generated/hooks/useUser; export default function UsersPage() { const { data: users, refetch } useUser.list(); return ( ul {users?.map(u li key{u.id}{u.email}/li)} /ul ); }这里生成的Hook已经完成了React Query tRPC的封装页面代码里不需要再手动写query key和mutation组合前端的类型也天然与后端对齐。整个流程跑下来核心操作就是写schema、跑generate、调hook三步。4. 踩过的坑与排查实录4.1 常见错误速查表工具做得多了总会遇到各种意外。这里整理一份高频问题与排查思路汇总现象可能原因处理方法生成的类型和手写类型冲突生成文件被改动或缓存未清理检查文件头是否有generated标记用--force重新生成windows下生成的import路径是反斜杠没用path.posix.join拼路由从Windows路径转为使用正斜杠v1.3.0后默认处理z.string().email()在旧数据上报错数据库中历史脏数据没有通过校验生成校验器时加一个loose选项或配置迁移脚本tRPC类型推导不出来router没有显式导出类型在appRouter.ts末尾加export type AppRouter typeof appRouter覆盖了手写代码没有放在保留区块内手写逻辑务必放在// t3code:user-custom-start/end注释之间生成时间随项目变大越来越慢watch模式下每次都全量AST解析开启增量缓存只对变更模型重新生成4.2 三个需要重视的细节第一个是生成代码的可读性。我最初让模板直接用JSON.stringify输出对象生成的文件很长很没有规律大家都不敢改。后来调整成按字段逐行生成并加入清晰的注释和分组标记可维护性立刻上来了。代码生成器的产出物必须是人类可读的代码而不是机器可读的文本这一点非常重要。第二个是Watchers的缓存策略。用chokidar监听schema文件变化没问题但如果每次变更都全量重新生成所有文件模型一多会有几秒的延迟开发体验很受影响。最终实现是解析完schema之后把DMMF结构缓存在内存里再根据单个模型文件的时间戳做局部刷新。监听流程只对变更模型对应的模板做重跑其他目录不动。实测一个50个模型的项目增量生成可以控制在800毫秒以内。第三个是可选字段的Unions声名。前端类型如果直接使用string | null很多UI组件库的受控组件会不认。生成类型时我统一做了数据库层保留null应用层转undefined的约定。这个约定解决了很多React表单初始值类型报错的坑。还遇到过一类比较隐蔽的问题多人协作时有人提交生成代码先于schema文件更新导致代码与实际模型不一致但CI又不会发现。针对这个我加了校验命令npx t3code checkcheck命令会重新生成代码到临时目录并和现有文件对比如果有差异就报错。把它挂进CI的pre-merge检查基本能杜绝生成物过期这个问题。5. 后续扩展方向与实际体会5.1 从生成器走向生态平台t3code目前可以覆盖模型层、校验层、router层的自动化但业务里还有很多重复模式有待抽象比如基于角色的权限中间件、软删除的逻辑、审计日志字段。我的计划是把这些做成官方插件包比如t3code/plugin-soft-delete会自动为模型增加deletedAt字段和对应中间件逻辑。另一个方向是适配更多ORM和框架。目前Prisma tRPC的组合对很多项目来说已经够用但Drizzle ORM的用户也在快速增长。模板机制已经为迁移到其他ORM铺平了道路理论上只要替换Prisma解析器为Drizzle的表定义解析器t3code就能支持Drizzle生态。5.2 代码生成物与CI/CD集成的经验团队引入t3code之后最明显的变化是review的diff变小了。以前一个功能改动经常带着十几个文件的手动类型同步现在这些同步代码统一由t3code在本地和CI中自动生成diff里只剩真正的手写业务逻辑。我给几个真实的工作流建议生成代码不进人眼review只跑check。在CI里加一步t3code check生成代码是否一致交给机器判断人工只review手写代码。首次接入时全量生成日常开发增量生成。尽量在feature分支初始化时全量生成一次之后靠watch模式增量同步。模板跟着仓库走。把自定义模板放在项目里比如templates/目录通过t3code.config.ts引用这样换人也能保持一致。5.3 个人体会最后再分享一个小设计t3code开发中最让我庆幸的一个设计是生成文件头部标记 generated目录隔离的组合。每个生成文件都会自动写入一行// GENERATED BY T3CODE - DO NOT EDIT THIS SECTION配合独立generated目录IDE的搜索默认排除这些文件git提交钩子也能检测到手动改动生成文件的行为并及时提醒。这个看起来不起眼的细节避免了至少十次生成代码被手改后无法再增量更新的灾难。如果你现在的项目也在经历前后端类型脱节带来的痛苦我建议先别着急写代码生成器可以先试试t3code或者类似思路的工具。哪怕只是把schema、校验、类型这三层先统一起来体验就会比三份各自独立好一个量级。工具不在多能真正把重复性劳动收敛成一个命令就是值得的。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询