t3code实践:TypeScript与tRPC实现全栈类型安全 “t3code”—— 先别纠结它名字里的玄机这就是一套冲着“极致类型安全”去的全栈开发实践代号。我在实际项目里把它拆成了 TypeScript、Tailwind可选但常用与 tRPC 的三板斧组合核心思路是从前端组件到数据库查询整条链路都让 TypeScript 类型帮你把关而不是靠手写接口文档和联调时的自觉。这篇东西不是教程复读而是我自己从零搭到上线、再从踩坑里爬出来的过程记录重点讲清楚为什么这么选、关键配置怎么落、以及哪些文档里不会明说的坑。1. 内容整体设计与思路拆解1.1 为什么是 tRPC 而不是 REST 或 GraphQL过去几年我写过不少 REST 接口Controller-Service-Model 那一套确实成熟稳定但维护成本全藏在“接口文档”和“类型定义同步”里。前端改一个字段类型后端要跟着改 DTO两边都改了还不一定对得上。GraphQL 解决了部分过载抓取问题但引入了 Schema 语言和维护成本。tRPC 的做法是把服务端函数直接暴露给客户端用 TypeScript 的泛型推理把类型带过去。写一个listUsers的 procedure前端 import 过来直接.query()就能拿到完整推导的结果类型。没有手写路由字符串没有单独的类型生成步骤也没有运行时 Schema 校验除非你加。这套模式最直接的价值类型定义只存在于一处改了后端函数签名前端立刻在编译期报红。有人担心 tRPC 是不是“玩具方案”。我在中等体量的内部工具和对外轻量应用中实测过只要团队能管好 query/key 的组织tRPC 的约束力比想象中强得多。1.2 三层架构的拆解t3code 实践里我把项目分成三层路由层tRPC Router负责暴露业务函数入口只做参数接收、鉴权、调用 service。服务层Service集中处理业务规则比如权限判断、数据组装、事务处理。数据层Prisma Client 或 Drizzle只做数据库读写尽量不掺业务逻辑。之前我把业务规则写在 tRPC procedure 里结果 router 文件越来越胖后来强制拆到 service 层测试和复用都轻松很多。这跟 NestJS 的分层思路一致但 tRPC 把它轻量化了没有依赖注入容器函数式写法照样清晰。1.3 选型权衡什么时候不要用这套任何方案都有边界。t3code 适合中小型全栈应用、内部系统、MVP 验证。但如果你的项目有大量第三方服务需要开放 API、或者有复杂权限模型且前端有多种客户端iOS / Andriod / Web还是 REST OpenAPI 或 GraphQL 生态更稳妥。我的判断标准很简单如果前后端都是 TypeScript 且由同一个团队维护tRPC 省下的时间非常可观如果存在非 TS 客户端比如小程序或外部合作伙伴老老实实走 REST。2. 核心细节解析与实操要点2.1 TypeScript 严格模式是第一道门槛t3code 的项目我开了strict: true而且推荐开noUncheckedIndexedAccess。这个选项会告诉你数组或 Record 取出来的值可能是undefined强制你去处理边界情况。很多人在这个配置下会写大量判空代码但这恰恰是类型系统的价值——它逼你想清楚空值策略。再配合satisfies关键字做对象字面量到类型的精确匹配能让配置对象既享受字面量推断又保留类型检查。我经常用在next.config或环境变量解析上效果明显。2.2 tRPC Procedure 的输入校验tRPC 的输入校验我默认用 zod。它和 TypeScript 配合得很好schema 即类型虽然数据在运行时会被验证一遍但类型层面的成本几乎为零。import { z } from zod; const createPostInput z.object({ title: z.string().min(1).max(100), content: z.string().min(1), tags: z.array(z.string()).max(5).optional(), }); export const postRouter router({ create: protectedProcedure .input(createPostInput) .mutation(({ ctx, input }) { return createPostService(ctx.db, ctx.user, input); }), });很多初学者忽略的一点input校验通过后tRPC 会自动把类型推到input变量上不需要二次断言。但要注意zod 的 schema 应该放在一个共享文件里方便服务端测试和前端类型引用。2.3 Context 的设计决定鉴权上限tRPC 的context是每个请求都会构建的我把db、session、user都塞进去然后 middleware 里再判断是否登录。不要图省事在 service 里直接getSession()那会让测试变得很难受。export const createContext async (opts: CreateNextContextOptions) { const session await getServerSession(authOptions); return { db, session, user: session?.user, }; };接着在 middleware 里做鉴权const protectedProcedure publicProcedure.use(({ ctx, next }) { if (!ctx.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, user: ctx.user } }); });这样每个受保护的操作都用同一个入口避免散落权限判断代码。2.4 路由拆分的艺术tRPC 支持嵌套 router我一般按模块拆userRouter、postRouter、teamRouter最后在根 router 里合并。但要注意过度拆分会让文件数量爆炸适度就好。我的习惯是两个维度——业务域和复杂度。业务域清晰就用域拆分某个域的逻辑特别多再考虑子路由。有个容易踩的坑procedure 名不要和生产接口路径混着来因为 tRPC 最终会按 router 合并后的结构生成调用链。改名后前端若未同步跑类型检查编译期就会出现调用错误。2.5 Prisma 建模与迁移流程t3code 实践里我数据库层用的是 Prisma主要是喜欢它的迁移流程和类型生成。schema 文件是一个单一事实来源改模型后执行prisma migrate dev自动生成客户端类型。这里有个细节建议把生成的 client 放到独立目录/lib/db避免被框架重新干预。npx prisma migrate dev --name add-post-cover这条命令会创建迁移文件并重新生成 Prisma Client。如果是 CI 环境用prisma migrate deploy且不执行交互命令。在建模阶段阶段性的建议是尽量给所有关联字段加onDelete: Cascade或SetNull否则删除父记录会直接抛外键约束错误。更关键的是用relation时 Prisma 会要求双向关联完整刚开始容易漏。2.6 为什么选 PostgreSQL 而不是 SQLiteSQLite 做原型很方便但 t3code 项目一旦涉及并发写入和 JSON 字段操作SQLite 的锁策略会拖后腿。PostgreSQL 配合 Prisma 的Json类型和enum类型支持更成熟。我用 Docker 起本地 PG 非常顺docker run --name t3-postgres -e POSTGRES_PASSWORDsecret -p 5432:5432 -d postgres:16实际部署时用托管数据库更省心本地开发保持和线上一致的版本是最重要的。3. 实操过程与核心环节实现3.1 初始化项目骨架我推荐直接从 create-t3-app 起步它会帮你把 Next.js、TypeScript、tRPC、Prisma、Tailwind 都配好省掉初始配置的时间。我通常在初始化后第一件事关掉next dev以外不必要的东西并把极简路由跑通。npx create-t3-applatest my-t3code初始化时选择 Next.js 的 App Router 或 Pages Router 要慎重。我最近几个项目都用的 App Router Server Components 风格但 tRPC 官方的 Server Client 适配有一些细节要注意。如果团队对 App Router 不熟Pages Router 反而更省事。3.2 从零配置 tRPC 到真实调用init 之后你会看到类似这样的结构src/ server/ api/ root.ts routers/ post.ts db/ schema.prisma auth.ts trpc/ server.ts client.ts我在真实项目中不会保持这个结构完全不变而是会把root.ts里的 router 列表当成模块注册表而不是写一堆逻辑。某个实际调用场景前端要拉取某个团队下的文章列表tRPC 的写法非常简单const { data } api.post.listByTeam.useQuery( { teamId: xx }, { enabled: !!teamId } );useQuery第一个参数就是输入第二个参数是 React Query 的配置。这里最容易忽略的是enabled——如果teamId还没准备好就发请求tRPC 会收到undefined可能出现意外行为。3.3 一个典型 CRUD 的完整实现以“团队成员管理”为例我把整个链路串一下schema.prisma 里model TeamMember { id String id default(cuid()) name String role String default(member) createdAt DateTime default(now()) updatedAt DateTime updatedAt teamId String team Team relation(fields: [teamId], references: [id], onDelete: Cascade) }tRPC routerexport const teamMemberRouter router({ list: protectedProcedure .input(z.object({ teamId: z.string() })) .query(async ({ ctx, input }) { return ctx.db.teamMember.findMany({ where: { teamId: input.teamId }, orderBy: { createdAt: desc }, }); }), add: protectedProcedure .input(z.object({ teamId: z.string(), name: z.string().min(1) })) .mutation(async ({ ctx, input }) { return ctx.db.teamMember.create({ data: { teamId: input.teamId, name: input.name }, }); }), remove: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { await ctx.db.teamMember.delete({ where: { id: input.id } }); return { ok: true }; }), });这样的写法初学者也能看懂但实际项目里我一定会把逻辑挪到 service 中尤其是要校验团队成员是否具备操作权限时。比如对addprocedure需要判断当前登录用户是否是 team 的 ownerasync function canManageTeam(db, userId, teamId) { const team await db.team.findFirst({ where: { id: teamId, ownerId: userId }, }); return Boolean(team); }在 service 中抛TRPCError而不是在 router 里堆代码这是最容易养成的好习惯。3.4 React Query 缓存策略tRPC 底层用的是 React Query缓存 key 对性能影响很大。默认情况下useQuery的 key 就是 procedure 的路径加上输入。所以当你调用api.teamMember.list.useQuery({ teamId })缓存即按团队区分同一个团队切换多次不会重复请求。但有一个坑如果你在同一个页面同时调用list两次一次给表格一次给下拉框其实只发一次请求这是 React Query 默认的缓存复用特性。但如果其中一次传了不同的enabled条件可能会导致预期外的重新请求。我通常的规则是把一个页面的所有 tRPC 查询保持一致的输入参数不要在页面内二次加工。3.5 环境变量配置与类型安全环境变量我喜欢用第三方库做运行时校验比如t3-oss/env-nextjs。它会拦截缺失变量避免应用启动后期才报错。import { createEnv } from t3-oss/env-nextjs; import { z } from zod; export const env createEnv({ server: { DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(1), }, client: { NEXT_PUBLIC_APP_URL: z.string().url(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, }, });这样在代码里引用env.DATABASE_URL时会带完整类型。有一个细节client变量一定要以NEXT_PUBLIC_开头且必须在runtimeEnv里映射否则构建阶段的类型推断会缺失。4. 常见问题与排查技巧实录4.1 tRPC 类型“飘红”的三种情形tRPC 的类型错误有时看起来像是魔法失效其实基本都是结构问题procedure 的 input 用了z.object但没给字段默认值前端传undefined时可能报类型不兼容。给 option 字段加.optional()或.default()能解决大部分。middleware 里改了 ctx 类型但没返回完整对象导致后续 procedure 无法读取ctx.user。记得返回{ ...ctx, ... }。router 合并时两个子路由出现同名的 procedure 路径类型推导变得奇怪。检查根 router 的注册顺序。有一次我的代码编译报错最后发现是tsconfig里的module或moduleResolution被某个工具改掉了。tRPC 对编译器配置比较敏感务必保持和 create-t3-app 的默认配置一致。4.2 迁移后 Prisma Client 类型不同步修改 schema 后忘记prisma generate前端会报找不到新的字段。这几乎是最常见的问题。我的习惯是每次 migrate 之后立刻跑一次 generate并且在 CI 里把 generate 放在 build 阶段避免漏掉。如果你的编辑器还残留旧类型重启 TS Server 或执行npx prisma generate刷新即可。很多人不知道 VSCode 需要手动重新加载 TypeScript 版本遇到奇怪类型问题先重启。4.3 useQuery 无限重试与错误提示tRPC 的 React Query 默认会重试三次但如果是UNAUTHORIZED这类错误重试毫无意义。我通常在createTRPCNext的配置里定义好全局retry规则然后对敏感请求单独覆盖const trpc createTRPCNextAppRouter({ config() { return { queryClientConfig: { defaultOptions: { queries: { retry: (failureCount, error) { if (error.data?.code UNAUTHORIZED) return false; return failureCount 2; }, }, }, }, }; }, });非常建议在开发环境开启reactQueryDevtools不然查缓存和请求状态全靠猜。4.4 跨域与 SSR 请求路径的坑开发时前后端在同一 Next.js 应用里一般没有 CORS 问题。但如果把 tRPC server 拆出去独立部署跨域请求若不配cors()中间件浏览器会拦截响应。t3code 项目用独立服务较少但如果有记得显式配置允许来源并且生产环境不要用*。SSR 场景下tRPC 的请求是发到服务端的此时httpBatchLink的url不能写http://localhost:3000这种硬编码要用相对地址或环境变量注入。否则服务端渲染时会有意想不到的失败。4.5 React Server Components 与 tRPC 的兼容问题App Router 下RSC 组件默认不能使用 tRPC 的 hooks因为它是客户端逻辑。我一开始尝试在 Server Component 里直接调用 tRPC server 函数绕过客户端请求结果代码逻辑越来越乱。后来干脆把需要数据抓取的部分限制在 Client Component 或 Server Action 里。如果非要让 Server Component 拿数据可以直接调用 service 层函数而不是走 tRPC procedure。这避免了鉴权在 RSC 里失效的问题。这是我最想提醒的一点不要把 tRPC 完全取代服务端内部调用逻辑它只是“跨客户端到服务端”的桥服务层复用才是关键。5. 部署与上线过程中的自动化配置5.1 使用 Docker 镜像部署 t3code我的常用部署方式是 Docker 多阶段构建。先安装依赖再跑构建最后复制产物和node_modules。这里有个关键点Next.js 的 standalone 模式可以大幅缩小镜像体积。FROM node:20-alpine AS base FROM base AS deps WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile FROM base AS builder WORKDIR /app COPY --fromdeps /app/node_modules ./node_modules COPY . . RUN pnpm prisma generate RUN pnpm build FROM base AS runner WORKDIR /app ENV NODE_ENVproduction COPY --frombuilder /app/.next/standalone ./ COPY --frombuilder /app/.next/static ./.next/static EXPOSE 3000 CMD [node, server.js]如果直接用默认 output镜像会多出一倍体积但 standalone 模式要求应用没使用未声明为 external 的额外依赖。tRPC 和 Prisma 在 standalone 下要特别注意serverExternalPackages配置否则运行时可能找不到模块。5.2 初始化脚本与迁移安全容器启动时不要自动执行prisma migrate deploy尤其是多实例部署时并发 migrate 会有随机失败。建议在 CI/CD 管道里单独跑一次迁移再滚动更新应用实例。我在上线前还会做一次prisma db seed用一套真实样式数据测试关键流程。很多人直接在生产库上手动加测试数据结果类型不符还得反工。5.3 日志与性能监控tRPC 项目的日志分两类一类是应用日志建议输出 JSON 格式并关联 traceId另一类是数据库慢查询日志。我在部署后第一时间接好这两类日志不然出问题时两眼一抹黑连是接口慢还是 SQL 慢都分不清。6. 总结与后续规划6.1 我对 t3code 的整体体会用 tRPC 做全栈开发的最大收益并不是“省代码”而是类型安全带来的重构勇气。老项目不敢改字段、不敢删接口因为担心前端和后端的约定悄悄脱节。t3code 模式下编译器就是契约改不动的地方会直接报错不用靠记忆和文档。这个模式也有它需要自律的地方不要把 service 层写成大泥球不要把所有输入校验堆在 zod schema 里而忽略业务规则校验。只要这两点做好t3code 项目可以长期健康演进。6.2 未来可拓展的方向我后续会在两个方向继续深化这套实践一个是给团队加一套基于 tRPC 的操作日志中间件每个写操作自动记录操作人和变更数据另一个是把现有 REST API 逐步迁移到 tRPC在网关层做兼容。前者属于增强可观测性后者属于业务升级。如果你也在做全栈 TypeScript 项目我的建议是先小范围试水把一两个内部页面改造跑通再评估是否全面铺开。这套方案不复杂但对团队约定和代码规范有一定要求。