把T3这套东西用了一年多,从个人玩具项目一路写到团队生产环境,今天聊聊我理解的t3code到底是什么状态。
先说结论:t3code在我手里不是一个框架,也不是某个脚手架导出的固定目录,而是一套让“类型从数据库一路长到浏览器按钮”的编码体系。真正跑起来之后你会发现,代码里大部分时间在跟类型系统打交道,而不是在修接口文档和联调Bug。这篇文章把我这一年多踩过的路径、掉过的坑、最后沉淀下来的工作方式,完整拆开写一遍,希望能给正在考虑这条路的人一个参考。
1. 从一张Schema表看懂t3code的底气
1.1 为什么类型安全能成为项目的结构性优势
很多人第一次接触T3这套技术栈,会被“全栈TypeScript”这种宣传词带偏,以为只是“前后端用一种语言”这么简单。真正让我留下来的原因,是它在数据模型层面就锁死了各层之间的一致性。
举个例子,我参与的一个企业内部知识库项目,一开始数据库里有一张文章表,后来要加“置顶权重”这个字段。传统前后端分离的开发流程是这样的:后端改表、改接口、改文档,前端等文档、改类型、改联调用例,中间只要有一个环节忘了同步,线上就会冒出来undefined is not a function。
但在t3code这种体系里,流程变成:改Prisma模型文件,跑一次迁移,然后tRPC路由里的类型、前端组件里拿到的类型、甚至表单校验规则,全部自动跟着变。如果哪个组件忘了处理新字段,编译阶段就直接红了,根本轮不到上线。
1.2 t3code项目的目录组织和数据流全景
我现在的标准T3项目,目录结构长这样:
prisma/ schema.prisma src/ server/ api/ # tRPC路由,按业务域拆分 db.ts # Prisma Client单例 auth.ts # NextAuth配置 app/ (routes)/ _components/ # 页面专属组件 lib/ utils.ts validations.ts public/这个结构最大的特点是:业务域从后到前是同一组文件在驱动。schema是真相来源,tRPC是自动生成的“胶水层”,页面组件消费类型时不需要写任何手动的interface映射。
1.3 数据流里的“三合一”节点:Prisma、tRPC、React Query
我自己理解t3code的数据流,可以压缩成一句话:Prisma的模型定义 = tRPCOutput的天然类型 = React Query缓存里的数据结构。
三个节点是同一份类型的三种存在形式。有次我要在列表页展示文章封面图的裁切比例,后端路由还没写,前端就已经能拿到类型提示了。这种体验一旦用上就回不去了,属于那种“一开始觉得是语法糖,后面发现是骨架”的东西。
2. 真实项目里被t3code逼出来的编码习惯
2.1 从“写完功能”到“写对代码”的转变
用t3code之前,我写代码的习惯是把功能跑通就完事。类型声明能少写就少写,不行就as any。快是快,但三个月之后回头看,根本不敢改那些模块。
换到T3体系之后,最大的变化是:你被迫在写每一段代码之前先想清楚数据的形状。比如写一个会议室的预定表单,我会先问自己:订会议室需要什么字段?时间冲突怎么处理?被拒绝时的错误该走哪个通道?这些问题没想清楚,类型系统会不让你写下去。
这不是麻烦,是帮你建立了一种“数据形状先行”的思维。我现在写新功能,第一件事不是打开编辑器,而是想清楚数据长什么样、谁会产生它、谁要消费它、哪些状态会变。
2.2 依赖注入和中间件的轻量玩法
T3这套体系里面,鉴权、日志、限流这些横切关注点,几乎都用tRPC中间件来做。之前在一个公开浏览但需登录后才可收藏的场景里,我只给收藏相关的路由挂了一个isAuthed中间件,而公开页面完全不受影响。代码很轻,但边界很清楚:
const isAuthed = t.middleware(async ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session, }, }); });这种中间件组合的写法特别适合团队协作——新人接手时,根本不用去翻业务代码找权限判断,路由声明里写着什么就是什么。
2.3 错误处理不再靠打招呼式联调
传统开发里,前后端联调时最怕接口挂了却没人知道为什么。在t3code的体系里,TRPCError成了标准的错误通道。前端不需要去猜后端返回的code到底是什么意思,因为类型定义已经把错误码的枚举摆在那了。
我把错误分成三类:
- 用户操作类:参数校验失败、无权限、资源不存在
- 业务状态类:库存不足、状态已变更、冲突
- 系统异常类:数据库连接失败、第三方服务超时
每一类走不同的错误码,前端在onError回调里统一拦截。后来我们团队定了一条规则:**任何接口如果找不出一个TRPCError来,说明边界定义还不完整,不能算做完。**这一条直接消灭了一半以上“线上看起来没问题、但日志一堆警告”的隐蔽Bug。
3. 我踩过的几个大坑,以及完整的排查链路
3.1 Prisma模型字段改了,但前端类型死活不更新
有段时间我给用户表加了个isStaff布尔字段,迁移跑完,后端route里也能正常返回,但前端组件里一访问就报类型不存在。
排查链路是这样的:
第一步,先确认prisma generate确实跑成功,node_modules/.prisma/client/index.d.ts里能看到新字段。
第二步,检查tRPC路由的返回类型——当时我把路由的返回写成了整体的select,选字段时没把isStaff带进去。Prisma的select会直接影响输出类型,不是全量返回。
第三步,确认前端实际使用的类型到底长什么样。在VSCode里把鼠标悬停在useQuery的返回值上,发现Output类型里根本没有这个字段,才知道是select漏了。
这个坑本质上是“Prisma的select裁剪了类型”,不是缓存问题。解决方式是定义一组可复用的select对象,而不是裸写内联字段。比如:
export const userPublicSelect = { id: true, name: true, image: true, isStaff: true, } satisfies Prisma.UserSelect;然后路由和前端都用这一个select,类型再怎么变化都不怕。
3.2 NextAuth会话类型引发的一连串连锁反应
还有一次是给项目加“按部门过滤”功能。部门信息存在User表里新加的department字段,但是NextAuth的Session类型里默认没有这个字段。前端页面里访问session.user.department,类型一直报错。
排查流程:
先看NextAuth Module Augmentation有没有正确声明:
declare module "next-auth" { interface Session { user: { id: string; department: string | null; } & DefaultSession["user"]; } }再看callbacks.session返回的session是否包含这个字段:
callbacks: { async session({ session, token }) { if (session.user) { session.user.id = token.sub!; // 这里必须从数据库查一次department const dbUser = await prisma.user.findUnique({ where: { id: token.sub } }); session.user.department = dbUser?.department ?? null; } return session; } }最后发现最隐蔽的一层:tRPC上下文里取session的类型已经跟着变了,但某个路由用了ctx.session.user.department做筛选时,因为数据库列名写错了,导致运行时全是NULL。类型检查全通过,但数据就是不对。
这给我一个教训:**Augmentation只是让类型通过,真正生效要靠callback里真正塞进数据。**类型安全和运行时正确是两回事,前者能帮你快速定位,但不能代替测试。
3.3 文件上传和预签名URL的边界问题
知识库项目需要支持图片上传,我没用文件直接塞数据库的方案,而是选择了对象存储的预签名URL。但上传成功后有个回调更新数据库图片地址的流程,绕来绕去很容易在类型上卡住。
排查下来的核心是三个点:
- 预签名URL的过期时间要和业务操作时长匹配,不能太短
- 上传完成后用返回的key发起
update的tRPC mutation,而不是让前端直接组装完整URL - mutation的入参类型要定义为
{ key: string },避免前端把随机字符串当成路径拼来拼去
这里的教训是:zod的校验不能只做在前端表单上,服务端入口的schema同样要管住。我一般用zod定义mutation的输入,再在router里统一挂一个validation中间件,避免有人绕过前端直接打接口。
4. 从单干到带团队:t3code的协作风气
4.1 分支策略和代码评审里最常被讨论的东西
带团队之后发现,t3code这套体系下的代码评审,讨论最多的话题从“功能对不对”变成了“类型边界清不清晰”。
这其实是好事。因为运行时报错通过测试和类型系统过滤掉了一大半,剩下的问题主要集中在这几类:
- Router拆分粒度是否合理(一个文件管太多业务域)
- API暴露范围是否安全(有没有把不该暴露的内部方法挂到了路由上)
- 中间件使用是否一致(有的路由加了鉴权,有的漏了)
我们定了一个评审检查清单:
- 新增加的路由是否描述了完整的业务语义,而不是一堆
getSomething的堆砌 - 输入schema是否用zod做了最小约束
- select字段集合是否可复用
- 鉴权中间件是否出现在所有需要它的路由上
有了这个清单之后,评审速度反而加快了,因为大家有了统一的判断标准,不用每次从头争论。
4.2 代码生成和“复制已有模块”的利与弊
t3code不存在完整的代码生成器能一条命令生成整个CRUD模块,至少我用下来没有特别完美的方案。不过,你可以用tRPC的createCaller在服务端直接调用路由做测试,这个比之前写REST接口时启动整套HTTP服务要轻量得多。
但团队协作里有个比较常见的坏味道:新人喜欢从一个已有的路由文件复制,然后改名字和字段,结果复制出来一堆冗余代码和没用的方法。我的处理方式是:定一个最小化的“路由范式”,每个业务域的文件都按统一顺序组织:输入schema、输出select、mutation声明、router导出。这样即使复制,至少结构是一致的,后面做自动化重构也方便。
4.3 六周从零到上线的真实经验
有一回我们接了个外包性质的小项目,六周时间要上线一个预约管理后台,三个人,两个是第一次用T3这套栈。
我们只做了一个关键决定:严格限定数据模型就是五张表,用户、预约、服务项、评价、通知。所有功能都是围绕这五张表展开的。
第一周先出schema和seed数据,第二周做核心预约流程和tRPC路由,第三周开始做管理端页面,第四周做权限和通知,第五周联调测试,第六周部署上线。整个过程里,没有出现“前端等接口”的空档期,因为类型一旦对齐,前端和后端可以并行开工,页面提前用mock数据渲染,接口一接上去类型直接匹配。
5. 某次生产事故让我彻底读懂了t3code的边界
5.1 一批历史数据的NULL字段引发的连锁崩溃
真正让我意识到“类型安全不等于运行时安全”的时刻,是一次上生产环境后出现的数据异常。
系统里有一张订单表,phone字段在旧系统里有些记录是NULL。新系统迁移后,Prisma正常生成了string | null的类型,前端拿到nullable值也没报类型错误。但后来某个输出报表的页面直接对phone调用了slice(),结果运行时炸了,TypeError亮了一屏。
当时排查链路大概是:
先看后台日志,找到抛错的API路由,再看是哪一个字段的访问路径没有判空。定位到是报表模块里一个很老的工具函数,直接假设phone一定是字符串。再往前追,发现这个工具函数用了any开局的类型断言,让类型穿透了判空检查。
这问题如果发生在类型检查严格的链路里,根本不可能出现。但“工具函数里用了any”就等于把整条链路的类型安全打了个洞。
5.2 从这次事故里带团队定的新规矩
事后跟团队复盘,定了三条新的编码规矩:
第一,代码里禁止新增any,存量any必须在提案里说明为什么需要、什么时候清掉。这一步直接掐断了类型安全链路里最容易被凿穿的孔洞。
第二,凡是nullable字段,在输出到组件之前必须做显式处理。要么用??提供默认值,要么在tRPC层做一次normalize,不让违和的值越界。
第三,报表、导出这类“数据汇总型”模块,不能直接消费原始数据库类型,必须先过一道DTO层,把边界值和旧数据里的脏值洗掉。
这个经历让我明白了t3code的准确定位:**它是一座很结实的桥,但如果你从桥上跳下去游泳,它也拦不住你。**工具能给你一条类型安全的康庄大道,但你自己写的any、自己埋的脏数据还是会反过来咬你。
5.3 类型安全不是银弹,但它极大地降低了盲猜成本
后来我把经验总结成一句话:“类型安全不负责防止逻辑错误,它负责防止‘低级错误在大意之下进入生产’。”
类型系统能拦住大部分拼写问题、字段不存在问题、参数类型不匹配问题,但业务规则上的错误,比如“满减活动计算顺序搞错了”“库存扣减逻辑写反了”,类型系统完全无能为力。这些还是需要靠单元测试、code review和真实的业务测试来兜底。
6. 给打算上t3code这套路的后来者一些建议
6.1 什么人适合直接上手T3技术栈
如果你是这三种情况,我建议直接上手:
- 个人开发者,想用一套技术栈把全栈项目打通,减少维护多套代码的心智负担
- 小团队,没有专职后端,但前端水平不错,希望用类型系统弥补一部分沟通成本
- 做内部工具或MVP验证,希望在最短时间内把数据库、API、界面串起来
反过来的情况是:如果你的项目有非常复杂的历史数据迁移、异构系统集成、或者大量第三方纯REST接口要对接,T3这套方案可以引入,但不要指望它包治百病。
6.2 初学路径和配置时最容易忽略的四个细节
第一,Prisma的seed脚本要尽早写好。没有seed数据的T3项目,开发时会陷入“数据库没数据没法调试,没调试就没法确定接口返回形状”的死循环。我们项目每一次改schema,第一件事就是同步更新seed,保证任何新的拉取代码的人都能靠一条命令跑起来。
第二,tRPC的Context初始化要小心。NextAuth session的读取是异步的,context初始化回调里如果漏了await getServerSession,后面所有路由的session都是undefined。这个Bug我见过好几个朋友踩过,表现形式是登录了但接口全报unauthorized。
第三,改Prisma模型以后一定要重新prisma generate,而且要提交.prisma相关的变更。团队协作里,有人只改了schema忘了提交生成文件,另一个同事拉代码后类型全是旧的,排查半天。
第四,server-only包要用起来。凡是直接操作数据库的模块,顶部加一行import "server-only",防止有人不小心在客户端组件里引入和调用服务端逻辑。这个习惯能避免一堆把Prisma带进浏览器的低级错误。
6.3 和传统REST方案相比,T3带来哪些真正的体验变化
拿我自己的体验说,以前用Express写REST API再让前端对接,每个接口都要自己写文档、自己维护类型。现在用了tRPC,所有服务端方法就像本地函数一样被调用,类型直接从后端自动延伸过来。
不过这也有个代价:tRPC跟HTTP的耦合方式跟REST不一样,不能简单地把每个tRPC过程映射成一个标准的REST端点。如果未来想做开放API给第三方开发者,tRPC这套方案需要额外写一层REST适配。我的建议是,如果项目明确有开放API的计划,从一开始就把tRPC和REST分成两层:内部用tRPC的舒服,对外用REST的标准。
还有一个体验变化是部署。T3的方案通常是一个Next.js应用全包了,部署时只需要一个Node服务,不需要另外单独起API服务。我的部署方案是一台小机器上跑一个Docker容器就够,数据库放独立实例,整体运维负担小了很多。在小流量的内部系统场景里,这种部署方式特别舒服。
6.4 用t3code半年以后,我的代码风格发生了哪些变化
最后说点感性的。用了这套体系大半年以后,我明显发现自己写代码的风格变了。
以前写代码是先写功能再补类型,类型大多数时候是“装饰”。现在写代码是先想数据形状再写功能,类型从“装饰”变成了“脚手架”。有时候还没动手写业务逻辑,仅仅是梳理清楚类型关系,就觉得这个功能已经成功了一半。
另一个变化是对重构的态度。以前重构是心里发怵的事情,改一个接口要全局搜索所有调用点,改完还得祈祷不要漏。现在类型系统把所有调用点都标出来了,你改完schema,不匹配的地方编译器直接亮红。这个体验上的差距,让我现在看到遗留项目里大段的any和手写的重复interface会觉得很不自在。
我并不是说t3code适合所有人、所有项目,但如果你跟我一样,深受“前后端各自为政、类型靠文档和自觉”的折磨,那这套从数据模型贯穿到界面层的编码方式,确实值得认真试一试。至少我在用它重写第一个真实项目时,最大的感触是:代码里的“差不多”变少了,确定性变多了。
最后再分享一个小技巧:如果你的项目也是从REST迁移到tRPC的,不要一次性把所有接口全换掉。先挑一个改动最频繁的模块做试点,跑通一轮之后再逐步铺开。这样既能验证这套流程在你们团队里是否顺畅,也能在迁移过程中积累自己的t3code路由模板和代码范式。踩过一轮坑之后你会回来感谢类型系统的。