资讯动态

从类型割裂到端到端类型安全:用T3 Stack构建全栈应用的实践

发布时间:2026/10/26 17:10:07 来源:尧图企业网站定制
做 t3code 之前我刚被一个前后端类型不同步的问题折腾了两天后端接口明明返回了{ fileUrl: ... }前端偏要把它拼成url来用TypeScript 检查一路绿灯到联调才发现字段对不上。这类问题在传统的“API 路由 手写类型声明”模式下几乎每周都会碰上。所以当 T3 Stack 这套组合出现在我面前时我第一反应不是“又一套全家桶”而是先琢磨它到底能不能真的解决类型割裂这件事。t3code 就是我用来验证 T3 Stack 的实战项目一个带用户体系和权限管理的团队文档工具功能不算复杂但前后端联动很频繁正好适合折腾。这篇文章会把我从选型、骨架设计、类型链路、权限处理到部署上线的完整过程和踩坑记录都倒出来给正在评估要不要切 T3、或者刚初始化完不知道从哪里下手的开发者一个参考。1. 从 API 路由的手写类型到 T3 Stackt3code 的选型逻辑1.1 我受够的不是接口是类型割裂之前的老项目用的是 Next.js 的 API Routes后端逻辑写在pages/api前端组件里用fetch手动调。为了类型安全我得维护两套东西后端用 zod 或者手写 dto 做校验前端再复制一份 interface。改动一个字段两边都要动漏改就是线上事故没漏改也费时间。最难受的是这种“复制粘贴式类型同步”完全靠自觉没有任何编译期机制兜底。t3code 这个项目立项的时候我就定了两个硬性要求一是核心业务模型得有不少关联关系用户、项目、文档、标签类型定义绝不能散落多处二是要把后端字段的“真实来源”和前端期望绑定在一起让编译器帮我把关。T3 Stack 的思路恰好就是围绕这个来的TypeScript 是地基tRPC 让前后端 API 的类型自动打通Prisma 让数据库 schema 成为模型类型的唯一真源Next.js 负责页面和渲染Tailwind 处理样式。这套组合的核心词就是“约定优于配置类型优于文档”。1.2 tRPC、GraphQL、REST API 三者的类型安全对比选型那会儿我拿 tRPC 和另外两条路线做了个对比放在一起看会更清楚为什么最终选了 tRPC。方案类型从哪来端到端类型安全运行时校验额外步骤学习成本REST 手写类型前端手工维护 interface无靠自觉需另外接 zod/class-validator每次写接口都重复低但维护成本高GraphQL codegen从 schema 生成强但需要 codegen 步骤依赖解析器实现要写 GraphQL schema、维护 codegen 配置偏高中小项目偏重tRPC从 router 定义直接推导强零额外生成步骤直接在 procedure 里挂 zod schema前后端同仓库即可低跟写普通函数没有区别GraphQL 方案类型安全确实强但对 t3code 这种体量的项目来说引入 schema 定义、codegen、缓存策略显得太重。tRPC 的做法更像“在服务端写一个函数前端直接调用这个函数”参数和返回值类型自动传递过去没有中间商赚差价。这也是它的核心价值不需要 schema 文件不需要生成代码类型是从代码里长出来的。1.3 什么情况下我不建议用 T3 StackT3 Stack 不是万能的我自己也划定了几类不适用场景大家在评估时可以对照一下。前后端不在同一个仓库或者后端要同时服务多个独立的客户端App、小程序、第三方开放 API。tRPC 的类型推导优势在这个场景下会被削弱因为类型没法跨仓库自动同步。团队刚接触 TypeScript还在跟泛型和类型体操搏斗这时候引入 tRPC 会叠加一层认知负担。有非常标准的 OpenAPI 规范需求需要对外发布接口文档并让外部团队对接REST OpenAPI 生态会成熟得多。项目规模大到需要拆分多个独立微服务服务间通过消息队列或 HTTP 通信tRPC 更适合单体应用内部打通跨服务边界时还是要退回到通用协议。t3code 是单人维护、模块集中的内部工具正好落在 T3 Stack 的舒适区。如果你也在做团队内部平台、中小体量全栈应用这套技术栈会很顺手。2. 项目骨架t3code 的目录结构、数据流与模块边界2.1 create-t3-app 初始化后的三个必要调整初始化项目用的是官方脚手架一条命令就能把整套结构拉起来npm create t3-applatest t3code交互式选项里我勾选了 Next.js、Tailwind、tRPC、Prisma、NextAuth。生成出来的模板已经能跑但直接拿它写业务会有点别扭我做了三处调整。第一模板默认把src/server/api/routers/post.ts当示例我直接删掉改成按业务域拆目录routers/auth.ts、routers/project.ts、routers/doc.ts、routers/tag.ts。第二把 zod 校验 schema 单独抽到src/server/validators避免 router 文件里堆满校验逻辑后续多个 router 复用同一套校验时也有地方放。第三把src/server/db.ts里的 PrismaClient 实例保留为全局单例这在本地开发没问题但要上 Serverless 部署还得再处理连接策略后面专门讲。这里有个新手容易忽略的点create-t3-app生成的路由是/api/trpc/[trpc]这是 tRPC 在 Next.js App Router 下的统一入口你不需要为每个业务接口单独建路由文件。我见过有人没搞懂这一点以为是和传统 API 路由一样按文件对应接口结果把业务逻辑全塞进了 route handler等于又把 tRPC 用回了 REST 的方式类型优势全丢了。2.2 App Router 模式下 tRPC 的挂载与 server 目录设计tRPC v11create-t3-app 当前默认版本在 App Router 下的入口文件长这样// app/api/trpc/[trpc]/route.ts import { fetchRequestHandler } from trpc/server/adapters/fetch; import { appRouter } from ~/server/api/root; import { createTRPCContext } from ~/server/api/trpc; const handler (req: Request) fetchRequestHandler({ endpoint: /api/trpc, req, router: appRouter, createContext: createTRPCContext, }); export { handler as GET, handler as POST };核心的类型和组织逻辑在src/server/api目录里trpc.ts初始化 tRPC定义createTRPCContext和基础procedure权限中间件也在这里加。root.ts把routers目录下的所有子 router 合并成appRouter并导出给 client 使用的类型。routers/*.ts具体业务逻辑一个文件一个业务域。这里的设计要点是routers 只负责定义 procedure 和业务逻辑不直接操作 HTTP 层。所有和请求上下文相关的信息比如 session、数据库连接全部通过 context 传递。这样 router 的代码是可测试的不依赖 Next.js 的请求对象。2.3 一个“创建文档并更新项目状态”请求的完整调用链我拿 t3code 里最典型的一个操作来说明整套数据流用户在某个项目下新建一份文档同时要把项目的“文档数”字段加一。页面上调用一次 mutationconst createDoc api.doc.create.useMutation({ onSuccess: () { utils.api.project.getById.invalidate(); }, }); createDoc.mutate({ projectId: cm0..., title: 需求文档, content: # 正文 });这里api.doc.create的类型是从 server router 自动推导出来的参数对象的字段、类型、可选性全部由 zod schema 决定。请求发出后tRPC client 会 POST 到/api/trpc/doc.create服务端先由 zod 校验输入再进入 resolver 执行 Prisma 调用最后把结果返回。前端拿到的返回值类型同样是从 resolver 实际 return 的PrismaPromise结果推导出来的。整个链路里前端不写任何一句话“这个接口返回什么类型”类型是后端定义完自动传过去的。我后续在 t3code 上新增接口时最直观的感受是写完 router前端 IDE 里立刻就能感知到新接口和参数类型不需要重新拉取任何 schema。3. tRPC Prisma 的类型链路从 schema 到组件类型是怎么一路贯通的3.1 类型推导的底层逻辑Procedure 的输入输出与 zod 校验器tRPC 的核心类型机制其实可以拆成两段。第一段是 zod schema 同时承担“运行时校验”和“静态类型”两个角色。比如// src/server/validators/doc.ts import { z } from zod; export const docCreateSchema z.object({ projectId: z.string().cuid(), title: z.string().min(1).max(100), content: z.string().default(), });这个 schema 在 resolver 里通过input(...)挂载后tRPC 会自动用z.infertypeof docCreateSchema推导出 input 的静态类型。你不需要单独写一个 interface。服务器端校验失败会返回BAD_REQUEST错误前端收到后可以直接读取错误信息展示而不会出现“类型合法但实际运行时缺字段”的情况。第二段是 tRPC 的 router 类型映射。每个子 router 会被拆成一套recordkey 是 procedure 的名字value 是带输入/输出类型的对象。最终通过createTRPCProxyClient的泛型参数暴露给前端。这就是为什么前端useQuery、useMutation能拿到完整类型提示的原因本质上是 TypeScript 的类型映射在发力。3.2 同工程复用server 侧类型如何在 client 侧生效t3code 因为是全栈同仓库前端代码可以直接import { api } from ~/trpc/react然后在组件里使用api.doc.getById.useQuery(...)。这里有个关键约束appRouter是类型唯一的真源不要拷贝一份类型定义到前端目录。我看到一些改造中的项目会把 server 的类型再手工复制一遍当“公共类型”这等于又回到手写同步的老路上了。服务端代码如果需要调用另一个 router 的逻辑比如某个 mutation 里要复用 doc 模块的查询逻辑应该用 tRPC 提供的 server callercreateCaller而不是直接在前端方式调用一次 HTTP。我在 t3code 的统计模块里就踩过这个边界问题一开始直接调用api.doc.getById它走的是 HTTP 链路还得处理身份校验二次传递后来改成创建 server caller 后整个调用变成内存中的函数调用类型和上下文都顺了。3.3 类型链路里最常见的四个坑及排查记录实际开发中类型链路不是一上来就丝滑的我把踩过的和团队里常见的坑总结成一张表方便对照排查。问题现象根本原因解决方式zod 校验通过但 Prisma 执行时报类型错误zod schema 里的字段类型跟 Prisma schema 不一致比如把Json字段写成string检查 schema.prisma 中对应字段的类型保持 zod 与 Prisma 的对齐必要时写一个辅助函数从 Prisma 类型推导出 zod schemainvalidate 刷新不生效invalidateQueries的路径字符串和实际 key 不匹配或 key 粒度比传入路径大确保传入的是完整路径如api.project.getById.invalidate()如果自定义 queryKey 需要注意结构一致前端调用时输入参数必须写全但运行时报DATA_TOO_LARGE一次查询返回了超大嵌套对象比如文档列表里把 content 全文也带上了用select或拆分 procedure 控制返回字段不要默认返回整张表服务端调用某个 router 一直超时或拿到空 context直接 import 了 client 的 proxy 而不是 server caller切换到createCaller确保 context 从当前请求传入这四个坑里第二个和第四个最容易迷惑人。invalidateQueries其实是通过比较路径前缀来匹配的如果你自定义了 queryKey 或者 hook 用了不同命名它可能无法自动匹上这会让你误以为 React Query 的刷新有问题。第四个则是 tRPC 架构上的一个隐含约定client proxy 是给浏览器用的服务端内部调用请走 server caller两边 context 来源不同。4. 认证与数据权限t3code 里最容易出 bug 的边界4.1 NextAuth 与 tRPC 集成时 session 类型覆盖的细节t3code 用 NextAuth 做登录认证表面上是常规操作但第一次跑通后我发现所有 session 相关的类型都不对session.user.id在 TypeScript 里直接报错。因为 NextAuth 只提供了name、email、image这几个默认字段你要在数据库里查出来的userId、role等自定义字段必须手动扩展类型声明。// src/types/next-auth.d.ts import { DefaultSession } from next-auth; declare module next-auth { interface Session { user: { id: string; role: string; } DefaultSession[user]; } }扩展之后tRPC 的 context 里就可以安全地读取session.user.id了。我还额外在 JWT 的 callbacks 里把 userId 挂到 token 上保证 session 失效前能追溯到用户。这里有一点容易漏改了类型声明文件之后必须重启 dev serverTypeScript 才会重新加载模块声明不然会一直显示旧类型。4.2 用 middleware 统一收口权限而不是在 router 里散着判断权限判断的规范做法是做一个受保护的基础 procedure而不是在每个 resolver 里手动检查if (!ctx.session)。t3code 里我是这样拆的// src/server/api/trpc.ts export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user?.id) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { userId: ctx.session.user.id }, }); });业务 router 里需要登录的接口全部用protectedProcedure只有注册、登录、公开信息才走publicProcedure。这样权限判断集中在一处新增接口的时候不会忘review 代码的时候也只需要看 procedure 类型。后面如果需要管理员角色再加一个adminProcedure在 protectedProcedure 基础上多查一次用户表。4.3 一次越权事故的复盘把 userId 放进 input 的教训t3code 踩过最严重的一个安全问题是我把一个删除文档的接口设计成了让前端把ownerId直接放在 input 里传。当时想着校验时对比一下ownerId ctx.userId就行结果需求一变改成“项目成员都可以删除项目内文档”我为了省事只校验了doc.projectId是否属于当前用户的项目列表却忽略了projectId 也是前端传入的。攻击者只要知道项目 id就相当于掌握了文档的删除权限。复盘之后我把所有跟权限相关的判断逻辑改成服务端数据驱动删除时先用 docId 查出文档再拿doc.createdById和当前 userId 对比同时查一次项目成员表确认关系。前端只传文档 id不再传任何归属类字段。这也是一个通用原则权限判断必须基于服务端查询出的归属关系不能基于客户端传入的归属字段。数据范围、角色、项目关系都是在数据库里确认后才有意义。5. 上生产之后Serverless 部署下 Prisma、迁移与冷启动的实战记录5.1 Prisma 在 Serverless 环境的连接池配置本地开发时PrismaClient 创建一条连接池直接连数据库没毛病。但部署到 Vercel 这类 Serverless 平台后每个请求会拉起一个独立执行环境如果每次都新建 PrismaClient连接数会随着并发请求数线性增加把数据库连接数打满。解决方式是双管齐下第一在src/server/db.ts里把 PrismaClient 挂到globalThis实现跨请求复用import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) globalForPrisma.prisma db;第二连接串上加上连接池限制参数。我用的托管 Postgres 支持在 DATABASE_URL 后面拼接参数例如附加connect_timeout10pool_timeout5等具体参数取决于数据库供应商。有的服务商还提供连接池代理地址对接 Serverless 时优先用这个而不是直连主库。这能有效控制单个环境下实际占用的连接数。5.2 迁移脚本与 Prisma Client 生成的构建编排另一个容易翻车的点藏在构建流程里prisma generate和数据库 migration 是两个不同步骤不能混为一谈。generate 生成 PrismaClient 代码必须在 next build 之前执行而 migrate deploy 是在生产环境把 schema 变更应用到数据库不应该在构建时执行。否则构建机器连不上生产数据库整个部署直接失败。我在 t3code 的 package.json 里做了这样的编排{ scripts: { postinstall: prisma generate, build: next build, db:migrate: prisma migrate deploy } }本地开发时改完 schema 先跑prisma migrate dev生成迁移文件并同步到本地数据库。部署到 Vercel 时在项目配置的 Build Command 里只保留next build数据库迁移单独在部署前手动执行prisma migrate deploy。这样每次变更 schema 都有一份明确的迁移记录不至于每次构建都去动生产库。5.3 冷启动和首屏体验的优化尝试Serverless 函数的首次调用会有一个冷启动时间t3code 的首页是个文档列表如果每次打开首页都触发一堆动态查询首屏会很慢。我做的第一轮优化是把菜单、用户信息这类几乎不变的数据改成静态生成在构建时用 Next.js 的 generateStaticParams 预取展示需要的前置数据动态的文档列表则用客户端渲染配合 React Query 的 staleTime 减少重复请求。这一轮做完之后还有个容易被忽略的点tRPC 的 bundle 体积。如果appRouter里有大量不常用的业务代码它们会一起被打进前端 bundle。t3code 的做法是尽量让不同业务域独立成文件Next.js 的代码分割可以把路由相关的代码拆成单独的 chunk避免所有人下载所有模块。我实测下来冷启动从 1.5 秒左右降到了 800 毫秒以内动态数据加载也没有明显变慢。做完 t3code我对 T3 Stack 的态度是可以用而且很适合“一个团队用一套 TypeScript 代码把全栈做完”的场景前提是你平面清晰、业务边界明确。最值得的收益还是那条类型链路从 Prisma schema 到 zod schema再到前端组件的调用参数全程不需要手工维护类型声明这省下的时间会随着项目规模增长越来越明显。如果你也想上手第一个建议就是先把校验 schema 抽到独立目录第二个建议是权限判断一律从数据库里拿依据。至于体验上的波折这篇里的坑基本覆盖了 80% 的情况照着排就行。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑