t3code实践:用T3 Stack打造端到端类型安全的TypeScript全栈应用

发布时间:2026/10/27 22:59:24
t3code实践:用T3 Stack打造端到端类型安全的TypeScript全栈应用

我最近把我们团队那个快被维护成本拖垮的内部系统,整个重写成了一套代号叫 t3code 的 TypeScript 全栈代码库。这个名字没啥玄虚,字面意思就是 T3 Stack 加上 Code,T3 栈具体指 Next.js、tRPC、Tailwind CSS 和 Prisma 这套组合。前前后后折腾了两个多月,上线之后效果超出预期:联调时间少了大概七成,很多前后端接口文档直接不用写了,因为类型从数据库一路能推到浏览器端的组件里。这篇文章就把整个项目的设计思路、技术选型、实操过程和我踩过的坑完整记录下来。

这份总结主要写给两类朋友:一类是受够了“前端改字段要苦等后端更新文档、跑起来才发现响应和预期不符”这种协作方式的 TypeScript 团队,另一类是准备从传统 REST API 架构转向端到端类型安全架构、但不知道从哪儿下手的开发者。如果你团队已经超过三个人,而且主要技术栈是 TypeScript,这文章里的思路大概率能帮你省下大量填坑时间。

1. 先把 t3code 说透——这到底是个什么项目

1.1 项目背景:为什么会有 t3code

重写前的系统是个典型的“前后端分离”项目:后端是 Node.js 写的 REST API,前端是 React SPA,中间靠 Swagger 文档维持沟通。听起来很正常对吧?但维护半年之后问题就全冒出来了。

最常见的场景是后端同学改了某个字段名,比如把userName改成nickname,Swagger 文档确实也更新了,但前端代码里还残留着旧字段,跑起来才发现接口返回的字段对不上,再加上联调环境网络慢,一次字段变更可能要浪费半天时间去排查到底哪一层断了。更头疼的是,这种错误往往会出现在最深层的列表渲染逻辑里,开发工具里看到的是undefined,实际原因是从数据库到 UI 中间某个环节的“契约”断了。

t3code 的核心思路就是把“前后端之间的契约”从人肉维护的文档,变成由类型系统从底层自动推导。数据库里有nickname,Prisma 生成的类型就有nickname,tRPC 路由最终返回给前端的数据类型里也一定带nickname。前端开发写错了字段名,编译器直接报错,根本轮不到运行时联调。

这个思路其实在 TypeScript 社区已经提了好几年,但真正能落地的完整方案并不多。t3code 选择的就是 T3 Stack 这条已经被验证过的路径,它把 TypeScript 的推导能力从头到尾串起来,形成了一个前端团队可以直接照做的模板。

1.2 核心思路:端到端类型安全到底是什么意思

“端到端类型安全”这个词乍一听有点玄,我用一个生活化的类比来解释。

想象你从网上下单买个组装家具,传统架构是:生产厂商(数据库)出一份纸质的零件清单(API 文档),快递员(接口服务)把零件送到你手里(前端),你对着清单一个一个核对、组装。问题是清单经常是旧版的,零件编号对不上,你只能可怜巴巴地打电话问客服(查文档、问后端),整个流程充满了不确定性。

t3code 的做法是:生产厂商不是给你纸质清单了,而是给你一个“只能组装正确零件”的授权系统。你拿到的每个零件都自带正确的连接件规格,装错了就是装不上,物理层面杜绝了装错的可能性。具体到技术层面就是三条链路:

  • Prisma Schema 定义数据库模型,生成类型化的 Prisma Client,数据库字段名在代码里是强约束的。
  • tRPC 服务端路由定义输入输出的 zod 校验,类型由函数签名自动推导,不需要单独维护 DTO 定义。
  • Next.js 客户端直接通过类型化的 hooks 调用 API,返回的数据类型已经被精准推导,组件里类型安全覆盖。

这三条链路连接起来,前端代码里从数据库字段到 UI 展示,中间再也不需要手写任何接口类型。一旦数据库变了,跑一遍prisma generate和类型检查,所有受影响的位置会一次性暴露出来。

2. 技术选型的三个关键决策

2.1 应用框架:为什么必须选 Next.js App Router

我在 t3code 之前也考虑过纯 Vite SPA 方案,毕竟团队对 React 已经很熟了,迁移成本看起来更低,但最终选择 Next.js App Router,原因有三个。

第一个原因是 App Router 的服务端组件(RSC)能力。内部系统虽然不需要 SEO,但很多页面其实有大量的初始数据加载逻辑。在传统 SPA 里,前端要经历“页面加载 -> 发请求 -> 拿数据 -> 渲染”四个阶段,而 App Router 允许我们在服务端直接把数据查好,渲染出完整的 HTML,客户端拿到的就是可交互页面,首屏体验明显更好,而且后端查询逻辑离数据库更近,性能也更可控。

第二个原因是目录结构的约束力。app/目录下每个页面都是一个 folder,配合page.tsx、layout.tsx这些命名约定,页面结构一目了然。相比之前 SPA 时代“路由配置散落在各处”的痛点,这个约定让新成员也能很快找到归属。

第三个原因是混合渲染的灵活性。t3code 里有一些页面需要服务端渲染(比如首页聚合数据),有一些页面需要强客户端交互(比如项目管理看板),App Router 正好允许这两种模式在同一项目里共存,而不是像 SPA 那样只能二选一。实测下来这套组合非常顺手。

2.2 API 层:tRPC 比 Swagger + 手写 Client 强在哪

这是整个 t3code 里争议最大也最核心的选择。

传统的 REST API 通常配 OpenAPI/Swagger 做接口描述,然后再用工具生成 TypeScript 类型定义。这套方案的问题是“一刀切”:自动生成的类型往往和实际数据结构存在偏差,而且接口一多,生成代码就非常臃肿。tRPC 的思路完全不同,它不是一个“描述 API 的类型生成器”,而是一个“API 本身就是 TypeScript 函数调用”。

在这种设计下,前端调用后端接口的体验被无缝整合进了语言层面:

const taskList = api.task.list.useQuery({ projectId });

taskList的类型直接由后端路由函数的返回类型推导出来,不需要看文档,不需要查类型定义。写错了就编译报错,改字段就全局类型重构,这就是 tRPC 能做到“从生成到推导”的根本原因。

当然,这个选择也有代价,后面我会单独讲适用边界。如果你的项目需要对外提供 API 给非 TypeScript 的第三方团队调用,那 tRPC 就不是最优解,老老实实做 OpenAPI 更合理。但对纯 TypeScript 的内部前端服务来说,tRPC 的效率优势和类型优势是压倒性的。

2.3 ORM:Prisma 让数据库 Schema 成为唯一事实来源

t3code 用 Prisma 做数据库访问层,跟 tRPC 的配合是相得益彰的。

Prisma Schema 文件里定义模型和关系之后,通过prisma generate就能生成完整的类型定义,这意味着数据库的“语言”和 TypeScript 的“语言”是同一种。我在迁移过程中最大的感触是:以前改一个表结构,要同时改 SQL 迁移文件、后端实体定义、API 返回 DTO、前端 interface,四处同步;现在只需要改schema.prisma一个地方,然后运行两条命令:

npx prisma migrate dev --name add_tasks_table npx prisma generate

数据库同步了,类型也同步了。迁移过程中团队定了两条规矩:第一,schema.prisma的变更必须单独提 PR,让所有人都能看到并 review;第二,每个字段的@default必须写清楚,否则旧数据的兼容会变成巨大隐患。

顺带说一下,Drizzle 这套新 ORM 我也试用过,SQL 生成更激进,性能上略有优势,但对复杂关系和迁移工具链的成熟度方面,Prisma 依旧更省心。t3code 选 Prisma 的核心原因就是团队协作时不需要花太多精力维护 ORM 映射。

2.4 类型安全之外的第四件套:Tailwind 的取舍

T3 Stack 的标配里还有 Tailwind CSS。很多技术同学一提 Tailwind 就会想到“一堆 class 名丑不丑”这种争论,但 t3code 项目中我更看重的是它带来的心智负担降低。

Tailwind 配合组件化 React 模式,可以把样式直接写在组件内部,不会出现传统 CSS 文件那种“改了样式,影响了一大堆不相关页面”的问题。配合 App Router 的布局系统,整个界面整体统一性很好。如果你团队已经习惯了 CSS Modules,这里我不鼓励强行迁移,但如果你正好在选型期,Tailwind 是值得投入时间的选项。

3. 从零搭建 t3code 的完整实操

3.1 初始化:create-t3-app 的一站式体验

t3code 的脚手架是直接用官方的create-t3-app生成的,团队不需要从零配置所有依赖。执行命令:

npm create t3-app@latest t3code

交互式配置里选择:Next.js App Router、tRPC、Prisma、NextAuth.js、Tailwind CSS。生成之后我额外做了一步清理:删掉了默认的示例路由和页面代码,然后把项目里的琐碎代码全部推翻重写,只保留通用的工具函数和规范配置。

一个值得留意的细节是,生成的项目里默认启用了严格模式的 TypeScript 配置。这里不要为了省事关掉 strict,tRPC 的类型推导能力正是建立在 strict 模式下才能发挥出来的。如果一开始觉得类型太麻烦,就关掉个别检查项,而不是全局关闭,后面会非常吃亏。

3.2 数据模型:以“任务管理”模块为例演示全链路

为了把 t3code 的完整体系讲清楚,我以项目里一个真实模块——任务管理来演示核心链路。这个模块包含用户、项目、任务三张表,关联关系是:用户拥有多个任务,项目包含多个任务。

prisma/schema.prisma里这样定义:

model User { id String @id @default(cuid()) name String? email String @unique tasks Task[] createdAt DateTime @default(now()) } model Project { id String @id @default(cuid()) name String tasks Task[] createdAt DateTime @default(now()) } model Task { id String @id @default(cuid()) title String status String @default("todo") dueDate DateTime? creatorId String creator User @relation(fields: [creatorId], references: [id]) projectId String project Project @relation(fields: [projectId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

主键用cuid()而不是自增 ID,这是为了避免在分布式环境下 ID 冲突,实测并发写入多的时候很有用。dueDate用可空DateTime,配合 zod 在接口层做校验,能避免“日期字段传递字符串”这种最基础的 bug。

3.3 tRPC 路由:定义业务接口的两个步骤

接下来是 t3code 里最关键的一步——定义 tRPC 路由。在server/api/routers/task.ts中,我先定义了一个任务列表查询和一个创建任务的 mutation:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; const createTaskSchema = z.object({ title: z.string().min(1).max(100), projectId: z.string().cuid(), dueDate: z.string().datetime().optional(), }); export const taskRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ projectId: z.string().cuid() })) .query(async ({ ctx, input }) => { return ctx.db.task.findMany({ where: { projectId: input.projectId }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(createTaskSchema) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title, creatorId: ctx.session.user.id, projectId: input.projectId, ...(input.dueDate ? { dueDate: new Date(input.dueDate) } : {}), }, }); }), });

这个文件里值得注意的地方有三个。第一是protectedProcedure,它从 NextAuth 会话中取用户信息,没有登录的用户直接不允许调用,鉴权逻辑收敛在路由层而不是分散在每个业务代码里。第二是 zod,REST 时代你要写 Joi、Yup 之类的校验,现在输入校验直接跟路由写在同一个文件里,肉眼可见地减少了跳转成本。第三是ctx.db,这里的 db 是前面初始化时候注入的 Prisma Client,每个 request 的上下文是干净独立的。

定义完 router 后,在根 router 里挂载:

export const appRouter = createTRPCRouter({ task: taskRouter, project: projectRouter, user: userRouter, }); export type AppRouter = typeof appRouter;

那个AppRouter类型是 tRPC 的魔法所在,客户端引用这个类型之后,就能自动推导所有路由的入参和出参类型。

3.4 页面调用:客户端代码是如何做到“不用查文档”的

在 Next.js 的客户端组件里,创建一个任务列表组件:

"use client"; import { api } from "~/trpc/react"; export function TaskList({ projectId }: { projectId: string }) { const { data, isLoading } = api.task.list.useQuery({ projectId }); if (isLoading) return <div className="p-4">任务加载中...</div>; return ( <ul className="space-y-2"> {data?.map((task) => ( <li key={task.id} className="rounded border p-3"> <span>{task.title}</span> <span className="ml-2 text-sm text-gray-500">{task.status}</span> </li> ))} </ul> ); }

这里api.task.list.useQuery的类型是后端 router 自动推导出来的,我在编辑器里输入api.task.list.时,IDE 会自动提示后续的入参结构,字段拼错了、类型不对,编译时直接爆红。这种体验一旦习惯了,你再回去写那种手动维护接口定义的项目会觉得异常痛苦。

创建任务的 mutation 写法也很直观:

const utils = api.useUtils(); const createTask = api.task.create.useMutation({ onSuccess: () => { utils.task.list.invalidate(); }, });

注意我没有手动做乐观更新,而是先用了最简单的“成功后失效缓存”策略,让列表自动重新获取数据。实测下来在内部系统里这个策略已经足够顺畅,后面需要的时候再替换成复杂一点的乐观更新。

4. 核心难点:让类型在整条链路里不丢失

4.1 为什么有时候类型会在这里断掉

tRPC 的类型安全是一条连续的链:数据库 → Prisma → tRPC router → 客户端。但很多人在实际项目中会发现,这条链在某个位置突然就断了。最常见的原因是团队没有严格遵守“单向数据流”的约定。

举个例子:某次后端同学觉得 Task 状态不能只支持todo和done,于是他把schema.prisma里的status字段改成枚举类型TaskStatus。如果只改了数据库、没有同步更改schema.prisma里的枚举定义,Prisma 生成的新 Client 类型确实会自动带上新值,但如果前端组件里还在 if 判断task.status === 'done',这个字符串和枚举之前的比较在编译时不会报错,但运行时可能因为TaskStatus.DONE和'done'不一致而出 bug。

所以 t3code 项目里我们强制约定:所有业务枚举必须定义在 zod 里,并且由 zod 作为前后端校验的“唯一权威”。比如:

const TaskStatusEnum = z.enum(["todo", "doing", "done"]); export type TaskStatus = z.infer<typeof TaskStatusEnum>;

这样不管数据库怎么变,代码里的枚举和校验始终同步,类型断点无处可藏。

4.2 超过一层的数据返回尽量用扁平结构

tRPC 的另一个隐蔽的坑是嵌套数据返回。很多初学者会直接把带 relations 的对象一口气返回给前端,觉得这样省事,比如:

return ctx.db.project.findUnique({ where: { id: input.id }, include: { tasks: true } });

在小项目里这样做没问题,但一旦某个页面需要把项目信息、任务列表、任务所属人信息三层嵌套一次拿全,返回的对象层级会非常深,类型推导虽然没问题,但实际使用时很容易造成前端“修改了其中一个引用就触发整棵对象更新”的性能问题。

t3code 里我们约定:超过一层以上的嵌套数据,一律拆成独立路由查。查询页面先拿项目列表,再用项目 ID 去查任务列表。这样做的收益是:前端的useQuery缓存可以按页粒度精确失效,数据更新后局部刷新,不用刷新整个页面结构,体验和性能都会更好。

4.3 文件上传这种非标准接口怎么处理

类型安全框架最尴尬的场景就是文件上传,因为二进制流本身不是 TypeScript 类型能覆盖的范围。t3code 里处理这个问题的方案是:不让 tRPC 直接走文件流,而是用预签名 URL 上传到云存储。

具体流程是:前端调用 tRPC 的getUploadUrl路由,拿到一个云存储预签名地址和文件访问路径,然后用这个地址走 HTTP PUT 把文件传上去,服务端对象存储直接完成写操作,最后再调用一次 tRPC 路由把文件访问路径写入数据库。这个链路看起来绕了一圈,但好处是 tRPC 的类型安全并未被破坏,上传过程中的二进制流始终没有经过业务接口层,减少了被篡改的风险,也避开了函数调用栈对大文件处理的限制。

5. 常见问题排查与避坑实录

5.1 Prisma 类型没更新?这个坑很隐蔽

我在项目里不止一次遇到这种场景:改了schema.prisma,也执行了npx prisma migrate dev,数据表都更新了,但 tRPC 路由里的类型还是旧版,编辑器里一直报红。

排查后才发现,问题在于prisma generate把 Client 生成到了node_modules/.prisma/client,这个目录默认被 TypeScript 和构建工具忽略或缓存了。解决办法很简单:每次迁移完数据之后,手动重启一次开发服务器,或者在 package.json 里配置一个组合脚本:

{ "scripts": { "db:sync": "prisma migrate dev && prisma generate && next dev" } }

另外还有一个技巧:如果你在 CI 环境里构建,记得确保prisma generate在next build之前执行,否则生产包里的 Prisma Client 可能是旧的。

5.2 Type instantiation is excessively deep:tRPC 泛型爆了怎么办

tRPC 的类型推导非常强大,但也不是没有代价。当你的 router 嵌套层级过多、或者单个 procedure 的输入对象有几十个字段时,TypeScript 可能报出 “Type instantiation is excessively deep and possibly infinite” 这样的错误。

第一次遇到这个错误的时候差点把我整崩溃,后来总结出几条实操经验:

  • 把大 router 拆成多个小 router,比如一个taskRouter下面再按业务域拆成taskQueryRouter和taskMutationRouter,不要一个文件几百行。
  • 输入的 zod schema 不要定义得太庞大,超过十个字段就考虑拆结构,比如taskCreateSchema里只放基础字段,扩展字段再组合。
  • 如果某个 procedure 的返回类型暴涨,优先检查是不是把整个 Prisma 的 include 对象返回了,换成 select 指定字段会显著降低类型复杂度。

5.3 Next.js App Router 和 tRPC 的协作细节

t3code 里大量使用了 App Router 的服务端组件,但 tRPC 的 Hooks 只能在客户端组件里用。这就产生了一个常见的困惑:服务端组件里怎么调用 tRPC 路由?

官方推荐的做法是用server侧的调用器,t3-app 脚手架已经封装好了。在服务端组件里调用:

import { createCaller } from "~/server/api/root"; import { createTRPCContext } from "~/server/api/trpc"; const caller = createCaller({ session: await getServerSession(), headers: await headers(), }); const tasks = await caller.task.list({ projectId: "xxx" });

但这里有一个必须注意的细节:headers()在 App Router 的服务端组件里可以用,但前提是当前组件处在“动态渲染”模式下。如果你在静态页面直接调用 caller,构建时会直接报错。解决办法是在用到 session 和 headers 的地方,保证页面是动态的,需要在page.tsx里加一行:

export const dynamic = "force-dynamic";

5.4 常见问题速查表

症状大概率原因解决动作
改了 schema,但代码类型没变Prisma Client 缓存或构建顺序问题重新prisma generate,重启 dev server
类型推断报 “excessively deep”router 嵌套过深或返回对象过大拆分 router,返回字段用 select
服务端组件调用 caller 构建报错组件处于静态渲染模式页面加export const dynamic = "force-dynamic"
客户端 hooks 不起作用没有在组件里加"use client"在组件文件顶部声明客户端组件
mutation 成功但列表没刷新没有失效相关 query 缓存在 onSuccess 里调用utils.task.list.invalidate()

6. 这套架构落地的收益与适用边界

6.1 重构之后的真实感受

t3code 上线跑了一个多月,我感受最深的不是“写代码变快了”,而是“讨论代码的方式变了”。以前我们 code review 要纠结半天“这个接口返回的字段到底是不是前端想要的”,现在全部变成“这个路由的输入校验够不够严谨”。前后端联调阶段从原来的“群里互相艾特找字段问题”,变成了“类型通过就能跑通,跑不通就会报类型错误”。

数据说话的话,旧的内部系统平均一个完整功能从开始开发到上线需要一周左右,其中联调至少占两天。重构之后大部分功能两到三天就能完成,联调时间压缩到半天以内。这种效率提升是实打实的。

6.2 什么情况下不建议照搬 t3code 这套方案

这里我要说点泼冷水的话。t3code 不是银弹,它有明确的适用边界。

如果你的产品要对外开放完整 API,让 Python、Java、Go 等不同语言、不同技术栈的团队来调用,那 tRPC 的“类型推导”优势就荡然无存了,对方拿不到你的 TypeScript 类型,只能靠文档。这种场景老老实实用 OpenAPI + Swagger 生成多语言 Client 是更稳的路径。

如果你的团队技术栈不统一,前端有人用 Vue 有人用 React,后端是 Python 团队维护,那么强行上 t3code 只会增加团队之间的沟通成本。这个方案最适合的场景是:团队技术栈统一为 TypeScript,业务以内部系统、管理后台、中台支撑为主,API 不需要对外部团队开放。

6.3 后续可以做的两个扩展

t3code 目前只是整个骨架的第一版,我已经在考虑两个后续方向。第一个是引入 t3-turbo 的 Monorepo 模式,把业务代码拆成 packages,前端的 app 和后端的业务逻辑独立发布,这样代码复用和版本管理会更好。第二个是给 key 业务流程接入更精细的缓存策略,tRPC 的准实时特性对数据一致性要求高的内部系统足够了,但如果后面要对用户开放门户,可能还得在数据源层做额外的缓存层。

最后留一个小技巧:t3code 项目里的src/trpc/shared.ts这个文件非常关键,所有的请求转换和类型提取都靠它。如果你遇到 “Could not find a declaration file” 之类的奇怪错误,先检查这个文件是不是被删了或者被改坏了。我在项目初期就因为一次重构误删了这个文件,排查了整整一下午才定位到问题,从那以后我再也不乱动这个文件了。

这套架构最让我满意的,不是某个技术多先进,而是它让整个团队的协作流程变得更顺滑了。如果你也在 TypeScript 技术栈里纠结前后端协作的痛点,t3code 这个思路值得你亲自试一遍。