T3Code:基于T3 Stack的全栈TypeScript类型安全实践

发布时间:2026/10/28 16:18:07
T3Code:基于T3 Stack的全栈TypeScript类型安全实践 做了这么多年全栈开发我最大的痛点就是前后端类型脱节后端定义好接口前端靠人肉拷贝一份类型声明字段一多、改一改编译期根本看不出来等接口一调才炸。直到最近我完整跑通了一个基于 T3 Stack 的实践项目——我给它起的代号叫 T3Code——才算彻底摆脱了这种“两头对不齐”的状态。T3Code 本质上是一套全栈 TypeScript 开发基座把 Next.js、TypeScript、Tailwind CSS、tRPC、Prisma 串成一条端到端类型安全的链路你在这头写一个函数另一头的页面组件立刻就能拿到推导出来的类型不用再维护第二份接口文档。这篇文章就是我在搭建 T3Code 过程中的完整记录包括选型思路、环境初始化、数据建模、tRPC 路由编写、认证集成以及我踩过的几个比较隐蔽的坑。适合想尝试全栈 TypeScript 的开发者也适合团队里准备统一前后端接口协作方式的人参考。1. T3Code 背后的核心思路为什么非要用 T3 StackT3Code 的技术底座是 T3 Stack这个名字里的“T3”代表 TypeScript、Tailwind CSS、tRPC但实际上这个组合里面还有 Next.js 和 Prisma。最初我看到 T3 Stack 的第一反应是“这不就是个技术名词拼盘吗”后来真正用它写完一个带数据库、带登录、带 CRUD 的业务系统之后才意识到这套组合最厉害的地方不在单个工具而在它们之间互相咬合的那条链路。下面我拆开讲。1.1 三个 T 的真正价值类型安全的起点不是编译器而是协议先逐个说。TypeScript 的价值不需要我多讲静态类型让重构变得相对安全这几乎是现代前端工程的标配了。Tailwind CSS 的价值在于效率原子化类名让 UI 开发不用频繁切换文件和命名 class我个人的体感写页面速度快了三成左右。而 tRPC 才是 T3Code 这条链路上最关键的一环——它不是 REST 也不是 GraphQL它是让你在服务端写一个函数然后在客户端直接调用这个函数连 HTTP 的 URL、方法、状态码概念都可以暂时忘掉。为什么会这样设计因为 REST 也好、GraphQL 也好本质上都是先定义协议再实现逻辑而协议和实现之间总会存在断裂。tRPC 的做法是跳过协议定义层直接用 TypeScript 的类型系统充当协议。你在服务端导出一个查询或变更函数T3Code 通过 tRPC 自动生成一个类型安全的客户端代理前端导入它之后IDE 的自动补全会直接告诉你这个函数接受什么参数、返回什么数据。所以“类型安全”不再靠编译期的一层薄薄约束而是从数据库查询语句一直推导到 React 组件的 props。1.2 T3Code 项目定位不是脚手架而是一个业务基座很多人会把 create-t3-app 生成的模板当成脚手架的终点用起来之后发现还要自己补一堆东西错误处理、日志、权限判断、统一的响应结构。我在做 T3Code 的时候刻意把这些通用能力都沉淀成了基座代码而不是散落在每个业务页面里。比如权限校验我封装了一个requireAuth的中间件函数放在 tRPC 路由的middleware里任何需要登录才能访问的路由只要挂上这个中间件就行不需要每个接口重复写判断逻辑。再比如错误处理。业务开发中经常遇到“数据库超时”“记录不存在”“参数非法”这类异常我统一封装了一个AppError类在 tRPC 路由里抛出来之后前端会拿到结构化的错误对象而不是一坨难以区分的运行时异常。这些看似不起眼的基础设施在维护到三个月以后才能体会到价值——因为那时候你已经不记得每个接口当初是怎么处理异常的了统一基座能省掉大量翻阅旧代码的时间。T3Code 的定位因此非常明确它不是一个生成完就丢的 demo而是一个可以直接承载业务迭代的工程骨架。如果你也打算拿 T3 Stack 做项目强烈建议花半天时间把基座层的约定先定好后面会顺很多。1.3 技术选型舍得为什么不选复杂方案在选型过程中我并不是没有犹豫过。团队里有人提议用 REST 手写 OpenAPI 文档也有人提议上 GraphQL 做数据聚合。但最终我全给否了。原因很简单T3Code 这个项目要的是“一个人或一个小团队能快速迭代”而不是“大团队多人多端协作”。如果上 GraphQL要先写 schema、接 resolver、处理 DataLoader 和缓存学习成本一瞬间就上去了。REST 虽然简单但类型安全要额外靠代码生成器、OpenAPI 客户端来维护鸭头很大。tRPC 在中小型全栈项目里确实是一个效率和确定性都很高的答案。2. T3Code 怎么做从初始化到数据建模决定了技术栈之后下一件事就是动手搭环境。这里我把 T3Code 初始化的完整流程和我在过程中做的决定记录下来每个步骤你都可以照着操作。2.1 初始化命令与选项细节T3Code 使用官方脚手架初始化命令如下pnpm create t3-applatest t3code注意我用的是pnpm比 npm 快不少而且对 monorepo 友好。执行之后脚手架会问你选哪些模块我这边勾选的是 Next.js TypeScript Tailwind CSS tRPC Prisma NextAuth因为 T3Code 需要用户体系和登录最后选了 ESLint。这里有一个细节脚手架会问你是否使用app router我建议直接选 Yes——现在 Next.js 的 App Router 已经是默认方向未来新项目只会也越来越多地使用这种方式没道理在新项目上继续维护 Page Router 的老模式。初始化完成后第一件事不是急着写代码而是检查整个目录结构。T3Code 的目录保持了很多最佳实践src/server放服务端逻辑包括 tRPC 路由、Prisma 实例src/app放 App Router 的页面文件prisma/schema.prisma放数据模型。这个分层很清楚服务端代码不会混入 React 组件里后来维护起来非常舒心。2.2 环境变量配置的坑T3Code 没接好环境变量之前页面能打开但一调用数据库接口就直接报错。这是因为脚手架生成的.env文件里只有占位符你需要自己创建.env并填入真实配置。我这边用的是 PostgreSQL配置如下#.env DATABASE_URLpostgresql://用户名:密码localhost:5432/t3code?schemapublic NEXTAUTH_SECRET一串随机生成的密钥 NEXTAUTH_URLhttp://localhost:3000NEXTAUTH_SECRET可以用openssl rand -base64 32生成一个。很多新手会漏掉这个变量导致登录时报错。如果你在前端页面调登录接口后出现 JWT 相关错误先检查这个密钥有没有配好。另外要强调一点.env文件不要提交到 Git 仓库T3Code 的脚手架已经默认把.env加进.gitignore了你只要老老实实建一个自己的.env别去动.env.example。2.3 数据模型设计与 Prisma 迁移配置好数据库连接后下一步是设计数据模型。T3Code 的业务主体是一个轻量级的任务协作系统所以我设计了三个主要模型用户User、项目Project、任务Task。模型之间的关系是一个用户可以有多个项目一个项目可以有多个任务。下面是简化版的 Prisma schemamodel User { id String id default(cuid()) name String? email String unique emailVerified DateTime? image String? accounts Account[] sessions Session[] projects Project[] } model Project { id String id default(cuid()) name String description String? ownerId String owner User relation(fields: [ownerId], references: [id]) tasks Task[] createdAt DateTime default(now()) } model Task { id String id default(cuid()) title String completed Boolean default(false) projectId String project Project relation(fields: [projectId], references: [id], onDelete: Cascade) createdAt DateTime default(now()) }这里面有几个点值得说明。第一主键我用的cuid()而不是自增整数这是因为在分布式或异步数据导入场景下cuid 不容易冲突而且客户端生成 id 的性能也更好。第二任务的project关系上加了onDelete: Cascade意思是删除项目时任务会跟着一起删避免留下孤儿数据。第三User模型里的accounts和sessions是 NextAuth 需要的不要轻易删。模型定义好之后执行迁移命令npx prisma migrate dev --name init这个命令会在数据库里创建表结构同时生成 Prisma Client。这里有个常见的困惑Prisma Client 生成的位置在哪在 T3Code 里脚手架已经把prisma/client集成好了但是每次修改 schema 后都要重新生成类型一定记得跑npx prisma generate否则 IDE 里会飘红。3. T3Code 核心实现tRPC 路由、认证与类型穿透数据模型就绪之后真正体现实力的是业务代码。T3Code 的 tRPC 部分把服务端逻辑和前端调用串成一条线我接下来逐步拆解。3.1 一段从数据库到 UI 的完整类型链路先看服务端。T3Code 在src/server/api/routers/project.ts里定义了一个简单的路由import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; import { z } from zod; export const projectRouter createTRPCRouter({ listMyProjects: protectedProcedure .query(async ({ ctx }) { return ctx.db.project.findMany({ where: { ownerId: ctx.session.user.id }, orderBy: { createdAt: desc }, include: { tasks: true }, }); }), create: protectedProcedure .input( z.object({ name: z.string().min(1).max(100), description: z.string().optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.project.create({ data: { name: input.name, description: input.description, ownerId: ctx.session.user.id, }, }); }), });这段代码的小知识含量不低。首先注意protectedProcedure——它不是普通的查询过程而是 T3Code 里我自定义的“必须登录”的过程内部通过中间件检查ctx.session是否存在如果没登录直接抛异常前端会收到 401 相关的错误。其次input用了zod做运行时校验不是只靠 TypeScript 的类型这样即使请求里传了多余字段或一个空字符串也会在进入数据库之前就被挡下来。前端调用这段逻辑时tRPC 会自动生成类型安全的方法import { api } from ~/trpc/react; export function ProjectList() { const { data: projects, isLoading } api.project.listMyProjects.useQuery(); if (isLoading) return div加载中.../div; return ( ul {projects?.map((project) ( li key{project.id} span{project.name}/span span任务数: {project.tasks.length}/span /li ))} /ul ); }注意这里projects的类型是从服务端的findMany返回值自动推导出来的——包括project.tasks这个关联数组这就意味着如果你在 Prisma 模型里改了某个字段前端 IDE 里立刻就会标出错误根本轮不到联调阶段去发现。这就是 T3Code 整条类型链路最让人上头的部分我实测下来改字段名字、改类型基本上几十秒内就能发现所有下游影响。3.2 认证过程接线NextAuth 与 tRPC 的上下文T3Code 的登录系统是 NextAuth 提供的。整条链路需要处理两个关键点一是认证配置本身二是如何把登录用户的信息塞进 tRPC 的上下文。T3Code 里在src/server/auth.ts配置了 NextAuth 的 provider这里我用的 GitHub OAuth也可以换成邮件密码登录但 OAuth 更方便演示。tRPC 上下文配置是这样的export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { db: prisma, session, }; };ctx.db是 Prisma 实例ctx.session是当前登录用户的数据。这也是为什么前面projectRouter里可以直接通过ctx.session.user.id拿到用户 ID——这中间没有任何手动解析 token 的代码NextAuth 帮你完成了 JWT 校验和会话还原。这里要提一个我踩过的坑在 App Router 下getServerAuthSession必须在每个需要会话的服务端请求里都调用不能在模块级别缓存 session 对象。比如你在某个页面组件里用了await getServerAuthSession()然后在另一个服务端函数里又调用了一次后者拿到的 session 依然是最新的因为每次调用都会重新走一次 Cookie 解析。如果你的代码中 session 莫名其妙变 null多半是因为缓存了旧对象。3.3 页面权限保护不止是隐藏按钮权限控制方面T3Code 除了在 tRPC 路由上用protectedProcedure做后端校验在前端路由层面也做了引导。Next.js 的中间件可以拦截请求我配置了一个简化版本export { default } from next-auth/middleware; export const config { matcher: [/dashboard/:path*, /settings/:path*], };这个中间件的作用是未登录用户访问/dashboard时会被重定向到登录页。有人会问前端拦截不就行了吗这里必须说明——安全边界永远在服务端前端拦截只是体验优化。如果只做前端按钮隐藏用户直接调接口还是能拿到数据。所以 T3Code 里后端校验是必须的前端拦截是辅助的这个顺序不要搞反。结合我的实践权限控制在 T3Code 里的实施路径是这样的路由层未登录访问受保护页面时重定向到登录页tRPC 层敏感操作使用protectedProcedure未登录直接抛错服务端校验层在路由逻辑里判断资源归属权比如只能操作自己创建的项目三层都走完才算一个相对严谨的权限模型。单靠任何一层都顶不住完整攻击面。4. 常见问题与排查技巧实录T3Code 从开发到部署我前前后后遇到了不少问题有些问题在网上搜不到直接答案靠读源码和试验才解决。这里整理出几个高频、隐蔽的问题脑海里留一份印象省得以后踩同样的坑。4.1 类型对不上前端拿不到ctx.session.user.id这个问题在初次搭建 T3Code 时几乎必出现。现象是后端代码里写ctx.session.user.id直接报错提示user对象上不存在id属性。原因很简单默认的 NextAuth 类型定义中session.user只有name、email、image几个可选字段并不包含id。解决办法是扩展类型声明// src/types/next-auth.d.ts import { DefaultSession } from next-auth; declare module next-auth { interface Session { user: { id: string; } DefaultSession[user]; } }然后在 NextAuth 的callbacks.session回调里手动把token.sub或user.id塞进去。如果不做这一步tRPC 路由里就很难拿到当前用户 ID很多操作就做不了。这种类型扩展的问题在 TypeScript 项目里非常常见学会通过模块增强补类型比粗暴用any强得多。4.2 tRPC 的 query 永远返回加载中我遇到过前端一直显示“加载中”但后端日志里根本没有这个请求到达的情况。后来发现是 tRPC 客户端没有和外部的 Provider 连接起来。在 T3Code 的 App Router 架构下你需要在根布局或者页面组件里包一层TRPCReactProvider——这是脚手架自动生成的组件里面使用了 Next.js 的headers()获取请求信息然后传递给 tRPC 客户端。如果你在测试某个独立组件时没有包 Provider组件内部的 query 就不会发起请求。解决方法是把请求逻辑放回页面组件里或者在测试环境手动挂载 Provider。另外确认一下src/trpc/server.ts和src/trpc/react.ts是不是保持了脚手架的默认结构这两个文件是 tRPC 客户端和服务端的桥删了或者改错了都会出问题。4.3 Prisma 改了 schema 后部署到服务器上表没变这个坑发生在部署阶段。本地开发时跑prisma migrate dev会自动迁移但是部署到服务器上你不能假设跑过migrate dev生产环境应当用prisma migrate deploy。脚手架不会自动帮你做这件事所以你需要在部署流程的构建或启动命令里加上npx prisma migrate deploy注意不是migrate devdev会尝试重建数据库或者要求交互式确认在生产环境一旦卡住整个部署就崩了。另外如果你使用 Docker 部署记得在容器启动命令里按顺序执行先prisma generate再prisma migrate deploy最后才启动 Next.js 服务。顺序不能反因为应用启动时 Prisma Client 需要存在且对应最新的 schema。4.4 环境变量在构建时读不到T3Code 部署到 Vercel 或者自建服务器时经常遇到DATABASE_URL is not defined这类错误。这往往是因为在构建阶段就尝试连接数据库了但环境变量没有正确注入。在自建服务器上排查思路是确认systemd服务或 Docker 环境里有没有设置环境变量而不是依赖.env文件未被加载在 Vercel 上需要到项目设置里配置 Environment Variables。有一个细节是如果你在代码里用了process.env.NEXTAUTH_SECRET但在构建阶段没有此变量Next.js 会直接把页面当成静态页面生成导致运行时才报错这种错误难以定位。我的习惯是任何服务端变量都在运行时读取如果必须在构建阶段使用就放到 next.config 的env字段里但慎用——因为这会暴露给浏览器端 打包产物不敏感的技能放这里还可以密钥绝对不能放。4.5 灰名单里include嵌套太深导致性能变慢T3Code 早期版本里的任务列表接口我用了两层includeProject 带 TasksTasks 又带 AssignedUser。数据量小的时候毫无感觉但几百条任务后响应时间明显增加。Prisma 的include是 SQL 的 relation join 语法糖但嵌套越深查询复杂度越高特别是带where条件时可能查出比预期多得多的数据。我的解决办法是把深层include拆开尽量在查询时只取需要的关系列表页只查分页数据详情页再查完整 model需要聚合计算的地方不要靠include后在 JS 里循环改用groupBy或写 SQL 视图这是 T3Code 性能优化里比较实用的几个策略和用 ORM 久了容易产生的偷懒习惯直接相关。5. 从 T3Code 到业务扩展的几点经验T3Code 跑通之后我又把它用在了另外两个内部小项目上一个是客服工单系统一个是节假日排班小程序。这个基座的扩展性给我留下了很好的印象但也不是没有教训。这里分享几点后续扩展时的体感以及一些我会提早做好的设计。5.1 路由拆分与模块边界T3Code 的 tRPC 路由目前是按资源拆分的projectRouter、taskRouter、userRouter。初期还好但随着业务模块增加路由文件会越来越多。我建议一开始就采用“按功能域组织”的方式而非“按资源组织”。比如工单系统里有一个“审批流”功能域它需要同时操作工单、审批记录、通知三个资源如果路由文件也按资源拆就会导致一个请求逻辑跨越多个文件维护起来很割裂。把同一条业务链路的流程放在同一个 router 文件里代码的可读性和可维护性都更好。5.2 缓存策略别急于引入有人会问 T3Code 有没有做服务端缓存。我的观点是先不做。tRPC 和 Next.js 的集成已经自带了很多缓存机制比如 Next.js 的 fetch 缓存、router cache如果你再加一层 memory cache很可能会在更新数据后出现旧数据残留。T3Code 目前的策略是所有写操作执行完直接调用前端 queryClient 的 invalidate 方法去失效对应的查询缓存然后让 tRPC 重新拉取最新数据。这个模式足够应对大多数中小型业务。等哪个接口的查询频率真高到成了瓶颈再单独做聚焦式缓存不要用一套全局缓存规则去套所有接口。5.3 测试策略别只测前端组件T3Code 的架构让后端逻辑的测试变得很容易因为 tRPC 路由本质上是函数只要 mock 一下ctx就可以测。我的习惯是把核心业务逻辑比如费用计算、权限判断、状态流转这些拆成纯函数单独测试tRPC 路由只做很薄的参数解析和上下文绑定。这样一来业务测试完全不依赖网络和数据库跑起来飞快。等到要测完整流程时再上 Playwright 做端到端测试。这个“纯函数优先、薄路由层”的设计是我后来在 T3Code 扩展过程中觉得最值回票价的决定。5.4 日志与可观测性最后一个经验是日志。T3Code 早期没有做统一的请求日志出问题全靠 console.log 打印线上环境根本看不见。后来我在 tRPC 的onError回调里加入结构化日志记录时间、路由、错误栈、用户 ID同时把日志输出到控制台部署时由进程管理器收集。这个小改动让排查效率提升了一个量级。如果你也打算长期使用 T3Code强烈建议在第一天就把日志埋点做进去不要等出事了再补。T3Code 这个项目到现在还在迭代但最基本的价值观已经稳定了让类型系统来守护全栈把重复的基建沉到基座里让业务代码保持薄和纯。如果你也想搭一套属于自己的全栈 TypeScript 基座可以直接照这篇文章的路径走一遍——你会感受到第一次从改数据库字段到页面全面标红再到一键修正完所有调用点的那种畅快感。