t3code全栈实践:类型安全与代码生成的高效开发指南
1. 从“t3code”这个名字说起它到底是什么第一次看到“t3code”这个词很多人会下意识地把它当成某个开源库、某个命令行工具或者某个内部代号。我最初接触它的时候也是这个反应翻了半天资料才发现它并不是一个官方命名的框架而更像是一个在开发者圈子里口口相传的“叫法”——用来指代一类把T3 技术栈和代码生成/代码组织方式结合起来的实践方案。T3 这个词在社区里通常指向一套以类型安全为核心的全栈组合TypeScript 打底配合 tRPC 做端到端类型推导再加上 Next.js 做前后端一体的渲染与路由数据库层用 Prisma 或 Drizzle 这类类型友好的 ORM。所谓“t3code”说白了就是围绕这套组合把“写代码”这件事从手写接口、手写类型、手写校验变成一套可以复用、可以生成、可以强约束的工程化流程。它解决的问题非常具体。传统全栈开发里最烦人的是什么是前后端各写一套类型接口改一个字段前端编译不报错、运行时才炸是表单校验规则写三遍前端一遍、后端一遍、数据库约束再一遍是新人接手项目光看接口文档就要花两天。t3code 这套思路的核心价值就是让类型成为唯一的真相来源让代码生成和类型推导替你做掉大量重复劳动。它适合谁适合已经会用 TypeScript、写过 React 或 Next.js、但被“接口对不齐”折磨过的中级开发者也适合想从零搭一个类型安全全栈项目的团队。哪怕你只是一个人做 side project这套东西也能让你少写一半样板代码。我下面要讲的不是某个官方文档的复述而是我自己在几个真实项目里落地这套思路时踩过的坑、总结出的流程以及那些文档里不会写的细节。你可以把它当成一份“抄作业指南”也可以当成一次同行之间的经验交换。2. 整体设计思路为什么是这套组合而不是别的2.1 类型安全为什么值得当成第一原则很多人对“类型安全”的理解还停留在“编译期不报错就行”。但在全栈场景里类型的价值远不止于此。我举个最朴素的例子后端定义了一个用户对象字段是id: string、nickname: string、createdAt: Date。如果前端是手写 interface那后端把nickname改成name的时候前端不会有任何提示直到用户点开页面发现昵称是空的。而如果前端类型是从后端推导出来的改字段的那一刻前端编辑器里就会飘红你被迫在编译前就处理掉这个问题。t3code 这套思路之所以把类型放在第一位是因为它把“类型”从一种文档性质的东西变成了运行链路的一部分。tRPC 的 router 定义在后端前端通过typeof appRouter直接拿到类型不需要任何代码生成步骤也不需要维护.d.ts文件。Prisma 的 schema 定义在schema.prisma生成的 client 自带完整类型查询结果直接就是强类型的。Next.js 的 server component 和 client component 之间传递数据也天然带着类型。整条链路下来类型是连续的、不中断的。这里有个关键判断类型安全不是目的减少沟通成本和运行时错误才是目的。如果一个方案让你写更多类型注解、维护更多类型文件那它是负担如果它让你少写类型、少对接口那它才是收益。t3code 属于后者这也是我选择它的根本原因。2.2 为什么用 tRPC 而不是 REST 或 GraphQL这个问题我被问过很多次。REST 的问题是接口契约靠文档和约定类型对不齐是常态GraphQL 解决了类型问题但引入了 schema 定义、resolver 编写、codegen 流程对中小项目来说偏重。tRPC 的定位很讨巧它假设前后端都是 TypeScript于是干脆不要 schema 文件直接用后端的函数签名当契约。我实测下来的感受是tRPC 在“单体全栈”场景下几乎是无敌的。你写一个getUser的 procedure前端调用trpc.user.getUser.useQuery()参数和返回值类型全自动推导改一个字段前端立刻知道。但它的边界也很清楚如果你的后端要同时服务 iOS、Android、第三方开放平台那 tRPC 就不合适因为那些客户端不是 TypeScript。所以选型前先问自己一句我的消费端是不是只有 TypeScript如果是tRPC 收益极大如果不是老老实实上 REST 或 GraphQL。2.3 数据库层为什么倾向 Prisma 或 Drizzle数据库层的选择直接决定了类型能不能“透传”到业务代码。Prisma 的优势是 schema 文件直观、migration 工具成熟、生成的 client 类型完整缺点是运行时偏重、复杂查询有时要写 raw SQL。Drizzle 的优势是更贴近 SQL、更轻、类型推导也很强缺点是生态和工具链相对年轻。我的经验是如果团队里有人对 SQL 不太熟或者项目需要频繁改 schema、依赖 migration 自动化选 Prisma如果团队 SQL 功底扎实、追求性能和可控性选 Drizzle。两者在 t3code 这套思路里都能用关键是不要让 ORM 成为类型链路的断点。所谓断点就是你从 ORM 拿到的是any那后面的类型安全就全废了。2.4 代码生成在其中的角色t3code 里的“code”其实有两层含义一是“写代码的方式”二是“生成代码”。很多人只关注前者忽略了后者。实际上这套思路里最省时间的就是代码生成。比如 Prisma 根据 schema 生成 clienttRPC 根据 router 推导类型Zod 根据 schema 生成校验逻辑甚至可以用 Plop 或 Hygen 根据模板生成 CRUD 页面骨架。我自己的做法是凡是重复三次以上的代码就考虑生成。一个典型的 CRUD 模块从 router、service、页面到表单校验手写要一两个小时用模板生成十分钟搞定剩下的时间用来处理真正的业务逻辑。这也是 t3code 这套思路能提升效率的核心原因——它把“写代码”变成了“配置 生成 少量手写”。3. 核心细节拆解每个环节的关键点和坑3.1 项目初始化别一上来就堆依赖我见过太多人一上来就把 tRPC、Prisma、NextAuth、Tailwind、Zod、React Hook Form 全装一遍结果跑不起来光排查版本冲突就花半天。我的建议是分三步走第一步用create-next-app起一个干净的 Next.js 项目确认能跑第二步加 Prisma跑通一次 migration 和一次查询第三步再加 tRPC跑通一个最简单的 procedure。这样做的好处是每一步都有明确的验证点。如果第三步出问题你就知道是 tRPC 配置的问题而不是“一堆东西混在一起不知道哪坏了”。我踩过的最大坑就是 Prisma 的 client 生成路径和 Next.js 的构建缓存冲突导致改了 schema 但类型不更新排查了很久才发现是缓存问题。后来我养成的习惯是每次改完 schema先手动跑一次prisma generate再重启 dev server。3.2 Schema 设计字段命名和关系要一次想清楚Prisma schema 是整个项目的根基改起来成本很高。我的经验是设计 schema 时至少想清楚三件事字段命名风格统一要么全 camelCase要么全 snake_case别混、关系字段明确一对多用[]多对多用中间表、时间字段统一用DateTime并加默认值。这里有个细节Prisma 默认生成的 client 里关联查询需要显式include否则拿不到关联数据。很多人第一次用会疑惑“为什么我的 user 里没有 posts”其实就是没写include: { posts: true }。这不是 bug是设计——它逼你明确知道自己要查什么避免 N1 查询。我一开始觉得麻烦后来发现这反而帮我养成了“按需查询”的习惯。3.3 tRPC Router 组织按领域拆别按技术拆tRPC 的 router 可以嵌套很多人会按“query / mutation”来拆我觉得这是反模式。更好的做法是按业务领域拆比如userRouter、postRouter、commentRouter每个 router 里既有 query 也有 mutation。这样前端调用时是trpc.user.getById、trpc.user.create语义清晰找代码也方便。另一个关键是context 的设计。tRPC 的 context 用来传递数据库连接、当前用户、请求信息等。我的做法是在 context 里放db和session然后在 procedure 里通过ctx.db和ctx.session访问。这样测试时可以轻松替换 context不用 mock 一堆东西。这里有个坑context 是每次请求都会创建的别在里面做重初始化操作比如重新 new 一个 PrismaClient那会导致连接池爆炸。3.4 输入校验Zod 是标配但别滥用tRPC 的 procedure 输入校验用 Zod 是标配因为 Zod 的 schema 既能校验又能推导类型一举两得。但我的经验是校验要分层前端表单校验用 React Hook Form Zod resolver后端 procedure 输入校验用 Zod数据库约束用 Prisma 的unique、db.VarChar等。三层各司其职不要指望一层搞定所有。有个细节很多人忽略Zod 的.parse()会抛异常而 tRPC 内部用的是.parse()所以校验失败会自动返回错误响应。但如果你在 service 层手动调用.parse()就要自己处理异常。我一般会在 service 层用.safeParse()根据结果决定是抛 TRPCError 还是继续这样错误信息更可控。3.5 前端调用useQuery 和 useMutation 的取舍tRPC 的前端 hooks 用起来很顺手但有几个坑。第一useQuery的enabled选项要善用比如依赖某个 id 才能查询时enabled: !!id能避免无效请求。第二useMutation成功后要手动invalidate相关 query否则数据不刷新。第三乐观更新optimistic update虽然体验好但回滚逻辑要写清楚否则出错时 UI 会不一致。我自己的习惯是读用 useQuery写用 useMutation写完必 invalidate。乐观更新只在交互频繁的场景用比如点赞、收藏其他场景老老实实等请求返回。这样代码简单出问题也好排查。4. 实操过程从零搭一个 t3code 风格的项目4.1 环境准备与依赖安装先确认 Node 版本我建议用 18 或 20 的 LTS 版本别用太新的有些依赖还没跟上。然后创建项目npx create-next-applatest my-t3-app --typescript --tailwind --app cd my-t3-app接着装核心依赖npm install trpc/server trpc/client trpc/react-query tanstack/react-query zod npm install prisma prisma/client npm install -D types/node这里注意trpc/react-query和tanstack/react-query的版本要匹配否则会出现 hooks 找不到的问题。我一般会先装tanstack/react-query再装trpc/react-query让 npm 自己解析版本。初始化 Prismanpx prisma init这会在项目根目录生成prisma/schema.prisma和.env。在.env里配置数据库连接本地开发我一般用 SQLite简单省事DATABASE_URLfile:./dev.db4.2 定义第一个 Schema 和 Migration在schema.prisma里定义一个最简单的 User 模型model User { id String id default(cuid()) email String unique nickname String createdAt DateTime default(now()) updatedAt DateTime updatedAt }然后跑 migrationnpx prisma migrate dev --name init这一步会生成 migration 文件并执行同时自动跑prisma generate生成 client。如果没生成手动跑一次npx prisma generate。我踩过的坑是有时候改了 schema 但忘了跑 migrate结果 client 类型还是旧的查出来的字段对不上。所以我的习惯是改完 schema 立刻 migratemigrate 完立刻 generate。4.3 搭建 tRPC 服务端先创建src/server/db.tsimport { PrismaClient } from prisma/client const globalForPrisma globalThis as unknown as { prisma: PrismaClient | undefined } export const db globalForPrisma.prisma ?? new PrismaClient() if (process.env.NODE_ENV ! production) globalForPrisma.prisma db这里用globalThis是为了避免开发环境下热重载时反复创建 PrismaClient导致连接数暴涨。这是官方推荐的做法我实测下来确实能避免“too many connections”的报错。然后创建src/server/trpc.tsimport { initTRPC } from trpc/server import { db } from ./db export const createTRPCContext async () { return { db } } const t initTRPC.contexttypeof createTRPCContext().create() export const router t.router export const publicProcedure t.procedure接着创建src/server/routers/user.tsimport { z } from zod import { router, publicProcedure } from ../trpc export const userRouter router({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { return ctx.db.user.findUnique({ where: { id: input.id } }) }), create: publicProcedure .input(z.object({ email: z.string().email(), nickname: z.string().min(1) })) .mutation(async ({ ctx, input }) { return ctx.db.user.create({ data: input }) }), })最后在src/server/routers/_app.ts里合并import { router } from ../trpc import { userRouter } from ./user export const appRouter router({ user: userRouter, }) export type AppRouter typeof appRouter4.4 接入 Next.js API Route在src/app/api/trpc/[trpc]/route.ts里import { fetchRequestHandler } from trpc/server/adapters/fetch import { appRouter } from /server/routers/_app import { createTRPCContext } from /server/trpc const handler (req: Request) fetchRequestHandler({ endpoint: /api/trpc, req, router: appRouter, createContext: createTRPCContext, }) export { handler as GET, handler as POST }这一步的关键是 endpoint 要和前端配置一致否则会 404。我见过有人把 endpoint 写成/api/trpc但前端配成/trpc排查半天。4.5 前端 tRPC Client 配置创建src/trpc/client.tsimport { createTRPCReact } from trpc/react-query import type { AppRouter } from /server/routers/_app export const trpc createTRPCReactAppRouter()然后在src/trpc/Provider.tsx里包一层use client import { QueryClient, QueryClientProvider } from tanstack/react-query import { httpBatchLink } from trpc/client import { useState } from react import { trpc } from ./client export function Provider({ children }: { children: React.ReactNode }) { const [queryClient] useState(() new QueryClient()) const [trpcClient] useState(() trpc.createClient({ links: [httpBatchLink({ url: /api/trpc })], }) ) return ( trpc.Provider client{trpcClient} queryClient{queryClient} QueryClientProvider client{queryClient}{children}/QueryClientProvider /trpc.Provider ) }最后在layout.tsx里包住 children。到这里一个最小的 t3code 风格项目就跑起来了。4.6 写一个完整的页面验证链路在src/app/users/page.tsx里use client import { trpc } from /trpc/client export default function UsersPage() { const { data, isLoading } trpc.user.getById.useQuery({ id: some-id }) const createUser trpc.user.create.useMutation() if (isLoading) return divLoading.../div return ( div p{data?.nickname}/p button onClick{() createUser.mutate({ email: ab.com, nickname: test }) } Create /button /div ) }这时候你改一下后端getById的返回字段前端编辑器立刻会提示类型变化。这就是类型链路打通的标志。5. 常见问题与排查技巧实录5.1 类型不更新怎么办这是最高频的问题。原因通常有三个Prisma client 没重新生成、Next.js 缓存没清、TypeScript server 没重启。我的排查顺序是先跑npx prisma generate再删.next目录再重启编辑器。三步下来基本能解决。如果还不行检查tsconfig.json的include有没有漏掉生成目录。5.2 tRPC 调用 404 或 500404 一般是 endpoint 配错检查前端httpBatchLink的 url 和后端 route 路径是否一致。500 一般是 context 创建失败或数据库连接问题看服务端日志通常是DATABASE_URL没配或数据库没启动。我遇到过一次是 SQLite 文件路径写错导致 Prisma 找不到数据库文件。5.3 Prisma 连接数爆炸开发环境下热重载会反复创建 PrismaClient导致连接数飙升。解决办法就是前面说的globalThis缓存。生产环境一般不会遇到因为进程只启动一次。如果用了 serverless 部署要注意每个函数实例都会创建连接这时候要考虑连接池方案。5.4 Zod 校验错误信息不友好Zod 默认的错误信息是英文的而且比较技术化。我的做法是用.refine()或自定义errorMap来定制错误信息。比如邮箱格式错误默认是“Invalid email”可以改成“请输入正确的邮箱地址”。这样前端直接展示就行不用再翻译一遍。5.5 前端 invalidate 不生效useMutation成功后要调用utils.user.getById.invalidate()注意utils是从trpc.useUtils()拿的。如果用的是旧版本可能是trpc.useContext()。invalidate 的 key 要和 query 的 key 完全一致否则不会刷新。我一般会在 mutation 的onSuccess里统一处理。问题现象可能原因排查动作类型不更新client 未生成 / 缓存跑 generate、删 .next、重启编辑器调用 404endpoint 不一致对比前后端 url 配置调用 500context 或数据库问题看服务端日志、检查 DATABASE_URL连接数爆炸PrismaClient 重复创建用 globalThis 缓存实例校验信息不友好Zod 默认信息自定义 errorMap 或 refineinvalidate 无效key 不一致检查 utils 调用和 key 匹配5.6 几个文档里不会写的经验第一别在 procedure 里写业务逻辑。procedure 只负责输入校验和调用 service业务逻辑放 service 层这样测试和复用都方便。第二错误处理统一用 TRPCError别直接抛 Error否则前端拿到的错误信息不结构化。第三日志要打在有意义的地方比如 service 入口和数据库查询前后别在 procedure 里打一堆。第四环境变量要校验用 Zod 在启动时校验一遍避免运行时才发现缺配置。6. 性能与扩展项目变大之后怎么办6.1 查询优化include 和 select 的选择Prisma 默认只查主表字段关联数据要显式include。但include会把关联表的所有字段都查出来如果关联表字段很多会造成浪费。更好的做法是用select精确指定要查的字段。比如查用户时只需要昵称和文章标题就写select: { nickname: true, posts: { select: { title: true } } }。这样生成的 SQL 只查需要的列性能更好。6.2 缓存策略React Query 的 staleTimeReact Query 默认staleTime是 0意味着每次组件挂载都会重新请求。对于不常变的数据可以设置staleTime: 5 * 60 * 1000五分钟内直接用缓存。对于实时性要求高的数据保持默认或设短一点。我的经验是列表页设长一点详情页设短一点写操作后 invalidate 对应 key。6.3 分页和无限滚动tRPC 支持useInfiniteQuery配合 Prisma 的cursor分页很好用。关键是 cursor 的设计一般用id或createdAt。注意 cursor 分页不要用 offset数据量大时 offset 性能差。我一般用createdAt加id组合避免时间相同导致重复。6.4 部署注意事项如果用 Vercel 部署Prisma 需要配置postinstall脚本跑prisma generate否则构建时会找不到 client。如果用 Docker注意把schema.prisma和 migration 文件一起打包。环境变量在部署平台配置别提交到仓库。数据库连接串在生产环境要用连接池避免连接数限制。7. 我个人的几点体会这套 t3code 风格的实践我用了大概一年多最大的感受是前期配置麻烦一点后期开发省心很多。类型链路打通之后改字段、加接口、重构代码都变得很踏实因为编译器会帮你检查。但也要清醒地认识到它不是银弹。如果项目很小、只有几个接口上这套东西可能反而增加复杂度如果团队里有人不熟悉 TypeScript学习成本也不低。我的建议是先从一个小模块试起比如用户模块跑通整条链路感受一下类型推导和代码生成的收益。觉得顺手再逐步推广到其他模块。别一上来就全盘重构那样风险太大。另外工具是死的人是活的遇到不顺手的地方该改就改别被“最佳实践”绑住手脚。比如我就把 tRPC 的 context 改成了自己的一套依赖注入方式用起来更符合团队习惯。最后分享一个小技巧把常用的 CRUD 操作写成 Plop 模板新模块直接生成能省下大量重复劳动。这个习惯我坚持了半年现在搭一个新模块基本十分钟搞定剩下的时间都花在真正的业务逻辑上。