1. 从“t3code”这个名字说起:它到底指什么
第一次看到“t3code”这个词,很多人会一头雾水。它不像“Python入门”或者“React教程”那样一眼就能看出领域,也不像某个大厂的开源项目那样自带光环。我最初接触到这个词,是在一个技术交流群里,有人发了一句“t3code 那套东西跑通了”,底下跟了一串“求分享”。当时我的第一反应是:这要么是个内部代号,要么是个极小众的工具链。后来花了不少时间翻资料、问人、自己动手试,才慢慢把它的轮廓拼出来。
先把结论放在前面:t3code 并不是某一个具体的软件或框架,它更像是一种围绕“T3 技术栈”展开的代码组织方式与工程实践的统称。这里的“T3”通常指向一套在现代 Web 开发圈子里流传甚广的组合——TypeScript、Tailwind CSS、tRPC,再加上一个全栈框架(比如 Next.js)。这套组合的核心卖点是“端到端类型安全”,也就是说,从数据库查询到前端渲染,类型信息一路贯通,编译器帮你把大部分低级错误挡在运行之前。而“code”这个后缀,则暗示了它关注的不是某个库的 API 怎么调,而是代码怎么写、项目怎么搭、团队怎么协作这一层的东西。
你可能会问:那为什么不直接叫“T3 技术栈实践”,非要造一个“t3code”的词?我的理解是,这个词带有一种“暗号”性质。在熟悉这套体系的小圈子里,说“t3code”大家就心领神会——聊的是怎么用 tRPC 定义路由、怎么让 Prisma 生成的类型自动流到前端、怎么在 Tailwind 的原子类里保持可维护性。它解决的不是“能不能跑”的问题,而是“跑起来之后代码乱不乱、改起来疼不疼”的问题。适合谁来参考?我认为有三类人:一是已经会用 React 和 TypeScript,但项目一大就感觉类型到处断链的开发者;二是正在选型全栈方案,想对比“传统 REST + 手写类型”和“tRPC 自动推导”差异的技术负责人;三是对“类型安全”这个词有执念,愿意为了编译期安心而接受一定学习曲线的人。
这篇文章不会给你一个“t3code 官方文档”式的复述,因为那东西本来就不存在。我会把我在实际搭建和重构中踩过的坑、总结出的模式、以及那些“文档里不会写但一写就错”的细节,按我自己的理解重新组织一遍。你可以把它当成一个过来人的笔记,而不是教科书。
2. 为什么“端到端类型安全”值得你折腾
2.1 传统全栈开发的类型断裂带在哪里
在 t3code 这套思路流行之前,绝大多数全栈项目的类型安全是“分段”的。后端用 TypeScript 写业务逻辑,数据库用 Prisma 或者 TypeORM 生成模型类型,这部分没问题。前端也用 TypeScript 写组件,props 和 state 都有类型。但中间那条 API 调用链,往往是类型丢失的重灾区。
举个我亲身经历的例子。后端有一个接口返回{ user: { id: string, name: string, avatarUrl: string | null } },前端调用的时候,很多人会随手写一个const data = await fetch('/api/user').then(r => r.json()),这时候data的类型是any。然后你访问data.user.avatarUrl,编辑器没有任何提示,运行时如果后端某天把字段名改成avatar_url,前端直接白屏。更隐蔽的是,如果后端把avatarUrl从string改成string | null,前端代码里那句data.user.avatarUrl.toLowerCase()在编译时完全合法,上线后遇到 null 就崩。
有人会说:那我手动写一个 interface 不就行了?可以,但问题在于手动维护的类型定义和真实后端实现之间没有任何强制关联。后端改了字段,前端那个 interface 不会自动报错,全靠人肉同步。项目小的时候还能靠记忆力,一旦接口超过二三十个,漏改一个就是线上事故。这就是所谓的“类型断裂带”——类型信息在 HTTP 这一层被彻底抹掉了。
2.2 tRPC 是怎么把类型“焊”回去的
t3code 体系里,解决这个问题的核心角色是tRPC。它的思路非常直接:既然前后端都是 TypeScript,那为什么还要通过 HTTP 协议来“约定”类型?直接把后端的路由定义当成类型源,让前端通过 TypeScript 的 import 来获取类型不就行了?
具体来说,你在后端定义一个 router,里面每个 procedure 的输入和输出都有明确的类型。tRPC 会从这个 router 的类型定义中推导出一个“类型契约”。前端在调用的时候,不是去fetch一个字符串路径,而是通过一个代理对象来访问,比如trpc.user.getById.useQuery({ id: '123' })。这时候编辑器能直接提示你id必须是 string,返回值里有哪些字段,每个字段是什么类型。如果后端把avatarUrl改成可空,前端所有没做空值判断的地方会立刻飘红。
这种体验上的差异,我打个比方:传统 REST 开发像是你给一个黑盒寄信,你只知道地址,不知道里面的人会怎么回你;tRPC 则像是你直接调用一个你认识的朋友的函数,他的签名你清清楚楚,参数传错了当场就被拦住。当然,代价是前后端必须共享同一套 TypeScript 类型系统,而且通常要求用 monorepo 或者至少能共享类型文件。如果你的后端是 Java 或者 Go,那这套就玩不转。
2.3 类型安全带来的隐性收益:重构不再靠勇气
很多人把类型安全理解为“少写几个 bug”,这没错,但我觉得它更大的价值在于改变了你重构代码的心理成本。在没有端到端类型安全的环境里,改一个数据库字段名是一件需要勇气的事——你得全局搜索、手动检查每一个 API 调用点、祈祷没有遗漏。而在 t3code 体系下,你改完 Prisma schema,运行一下类型检查,所有受影响的前端组件会像多米诺骨牌一样依次报错,你只需要顺着错误列表一个个修过去就行。
我印象很深的一次,是把用户表里的username拆成firstName和lastName。在旧项目里这种改动我至少预留半天,还得写一堆回归测试。但在 tRPC 项目里,我改完 schema 和对应的 procedure,tsc --noEmit一跑,二十几个报错点整整齐齐列出来,我花了四十分钟全部修完,而且心里非常踏实——因为编译器已经帮我确认了每一个引用点都被处理了。这种“编译器当导航”的感觉,是 t3code 最让人上瘾的地方。
3. 搭建一套 t3code 风格项目的关键决策点
3.1 项目结构:monorepo 不是必须,但强烈建议
t3code 这套东西要跑得舒服,前后端共享类型是前提。最直接的做法是把前端和后端放在同一个仓库里,用 pnpm workspace 或者 Turborepo 来管理。典型的目录结构长这样:
apps/ web/ # Next.js 前端 server/ # 后端逻辑(如果不用 Next.js API 路由) packages/ api/ # tRPC router 定义 db/ # Prisma schema 和 client config/ # 共享的 tsconfig、eslint 配置为什么强调 monorepo?因为 tRPC 的类型推导依赖于 TypeScript 能直接读取到 router 的类型定义。如果你把前后端拆成两个独立的 npm 包,每次改后端都要先发布一个新版本,前端升级后才能看到类型变化,那开发体验就全毁了。monorepo 让类型变更实时可见,保存文件就能看到前端哪里报错。
当然,如果你用的是 Next.js 的 App Router,其实可以把 tRPC 的 router 直接放在app/api/trpc/[trpc]/route.ts旁边,前后端在同一个 Next.js 项目里,连 monorepo 都省了。但一旦项目规模变大,后端逻辑复杂到需要独立部署,还是拆开更清晰。我的建议是:小项目直接 Next.js 全栈,大项目上 monorepo,不要为了“架构先进”而提前复杂化。
3.2 数据库层:Prisma 与 tRPC 的类型接力
在 t3code 的典型组合里,Prisma 负责数据库 schema 和类型生成,tRPC 负责把这些类型暴露给前端。这两者之间的衔接有一个关键细节:Prisma 生成的类型是数据库行的形状,但前端往往需要的是经过裁剪或组合的 DTO。如果你直接把 Prisma 的User类型返回给前端,会把passwordHash这种敏感字段也带出去,而且前端会依赖数据库的内部结构,以后改表就很痛苦。
我的做法是在 tRPC 的 procedure 里显式地做一层“输出映射”。比如:
const userRouter = router({ getProfile: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input, ctx }) => { const user = await ctx.prisma.user.findUnique({ where: { id: input.id }, select: { id: true, name: true, avatarUrl: true, createdAt: true } }); if (!user) throw new TRPCError({ code: 'NOT_FOUND' }); return user; // 类型自动推导为 { id, name, avatarUrl, createdAt } }), });这里用 Prisma 的select而不是include,就是为了精确控制返回字段。tRPC 会自动把这个返回类型推导到前端,前端拿到的就是干净的 DTO,而不是整个数据库行。这一步不做,后面前端就会到处出现user.passwordHash这种不该存在的访问,虽然不会真的泄露(因为你可以不返回),但类型上允许访问本身就是一种污染。
3.3 输入校验:Zod 不只是“顺便用用”
tRPC 的 procedure 输入校验通常用 Zod。很多人把它当成一个“可选的装饰”,觉得反正 TypeScript 已经有类型了,运行时校验多余。这是个危险的误解。TypeScript 的类型在编译后全部消失,运行时没有任何保护。如果前端传了一个id: 123(数字)而不是id: "123"(字符串),TypeScript 编译时可能因为类型断言而放过,但数据库查询就会出问题。
Zod 在这里的角色是“运行时的类型守门员”。你定义z.object({ id: z.string().uuid() }),那么任何不符合 UUID 格式的输入都会在进入业务逻辑之前被拦截,并返回一个结构化的错误。这比你在业务代码里写一堆if (!isValidUuid(id)) throw ...要干净得多,而且 Zod 的 schema 可以同时用于前端表单校验,一份定义两处使用。
我踩过的一个坑是:Zod 的.optional()和.nullable()混用。.optional()表示字段可以不存在,.nullable()表示字段可以存在但值为 null。在 tRPC 的输入里,如果你写z.string().optional(),前端传null会报错;如果你写z.string().nullable(),前端不传这个字段也会报错。正确的做法是根据业务语义选择,或者用.nullish()同时允许两者。这个细节在文档里一笔带过,但实际写起来十有八九会搞混。
4. 那些只有真正写过才明白的坑
4.1 循环依赖:tRPC router 拆分的隐形杀手
当项目变大,你肯定会想把 router 拆成多个文件:userRouter、postRouter、commentRouter。然后你会在userRouter里引用postRouter来获取用户的帖子,又在postRouter里引用userRouter来获取帖子的作者。这时候 TypeScript 会报一个让人摸不着头脑的错误:'userRouter' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
这就是循环依赖。tRPC 的类型推导是递归的,当两个 router 互相引用时,TypeScript 无法在编译期完成类型计算。解决办法不是加类型注解(那会丢失推导),而是把共享的逻辑抽到独立的 service 层。比如用户和帖子的关联查询,不要在两个 router 里互相调用,而是写一个getUserWithPosts的函数放在services/目录下,两个 router 都调用这个函数。这样 router 之间就没有直接依赖,类型推导也能正常完成。
这个坑我在三个项目里都遇到过,每次都是先愣住,然后花半小时才想起来是循环依赖。如果你看到 tRPC 报“隐式 any”但代码里明明有类型,第一反应就该去检查 router 之间的引用关系。
4.2 中间件里的 ctx 类型扩展
tRPC 的context是贯穿所有 procedure 的共享对象,通常用来放数据库连接、当前用户 session 等。默认的 ctx 类型是你在创建 tRPC 实例时定义的。但当你写中间件(比如鉴权中间件)时,你会想在 ctx 里加一个user字段。这时候如果直接ctx.user = ...,TypeScript 会报错,因为原始 ctx 类型里没有user。
正确的做法是用 tRPC 提供的MiddlewareFunction泛型来扩展 ctx 类型。但这里有个细节:中间件的执行顺序会影响 ctx 的类型收窄。如果你有一个authMiddleware保证 ctx 里有 user,那么用了这个中间件的 procedure 里,ctx.user 应该是非空的。tRPC 支持通过.use(authMiddleware)来让后续的 ctx 类型自动收窄。但如果你把中间件加在了错误的位置,或者用了publicProcedure而不是protectedProcedure,类型就不会收窄,你仍然需要手动判空。
我的经验是:把鉴权中间件封装成一个protectedProcedure,所有需要登录的接口都用它,不要在每个 procedure 里手动检查 ctx.user。这样类型系统会帮你保证,只要用了protectedProcedure,ctx.user 就一定存在,业务代码里可以直接访问,不用写if (!ctx.user) throw ...。
4.3 前端调用时的“类型正确但运行时错误”
tRPC 保证了类型层面的安全,但它不保证运行时数据一定符合类型。最典型的场景是:后端返回一个Date对象,类型上是Date,但经过 HTTP 序列化后变成了字符串。前端拿到的是字符串,但 TypeScript 以为它是Date,你调用.getFullYear()就会报“不是函数”。
这个问题在 tRPC 里有一个专门的解决方案:superjson。它是一个序列化库,可以保留 Date、Map、Set 等复杂类型的结构。你需要在服务端和客户端都配置 superjson 作为 transformer。配置之后,Date 在传输过程中会被标记类型,前端反序列化后仍然是 Date 对象。
但 superjson 也不是没有代价。它会增加传输体积(因为要附带类型元信息),而且如果你用了其他序列化格式(比如直接返回 Buffer),可能会遇到兼容问题。我的建议是:如果项目里大量使用 Date 类型,superjson 是必装的;如果只是偶尔用,可以考虑在 DTO 里统一用 ISO 字符串,前端需要时再手动转 Date。两种方案各有取舍,关键是团队要统一,不要一半接口用 Date 一半用字符串。
5. 从“能跑”到“好维护”:t3code 项目的工程化细节
5.1 类型检查不能只靠编辑器
编辑器里的类型提示是实时的,但它只检查你打开的文件。一个大型项目里,你改了一个共享类型,可能影响几十个文件,但编辑器只会提示你当前打开的那几个。必须把tsc --noEmit加入 CI 流程,每次提交前跑一遍全量类型检查。
我在一个项目里吃过亏:本地编辑器看着一切正常,提交后 CI 挂了,因为有一个深层嵌套的组件引用了被修改的类型,但我从来没打开过那个文件。后来我把pnpm typecheck加到了 pre-commit hook 里,虽然提交慢了几秒,但再也没出现过“本地绿、CI 红”的情况。
另外,不要用skipLibCheck: true来掩盖问题。这个选项会跳过 node_modules 里的类型检查,确实能加快编译速度,但如果某个第三方库的类型定义和你的 TypeScript 版本不兼容,你会错过警告,直到运行时才炸。我的做法是:开发时开启skipLibCheck加快反馈,CI 里关掉它做全量检查。
5.2 ESLint 规则要跟着 t3code 的习惯走
t3code 这套体系有一些特定的代码模式,比如 tRPC 的 procedure 定义、Zod schema 的写法、Prisma 查询的 select 用法。如果团队里有人不熟悉,很容易写出“类型能过但风格不一致”的代码。这时候 ESLint 的自定义规则就很有用。
我推荐几个在实际项目里证明有效的规则:
@typescript-eslint/no-explicit-any设为 error,禁止用 any 绕过类型检查。@typescript-eslint/no-unnecessary-condition设为 warn,它会提示你那些“永远为真”的条件判断,帮你发现类型收窄后的冗余检查。- 自定义规则:禁止在 tRPC procedure 里直接返回 Prisma 的完整模型,强制使用 select 或显式映射。
最后一条需要自己写 ESLint 插件,但投入产出比很高。它能在代码审查之前就拦住那些“图省事直接返回整个 user 对象”的提交,避免敏感字段泄露和类型污染。
5.3 测试策略:类型安全不能替代运行时测试
端到端类型安全给了你很大的信心,但它不能替代集成测试和端到端测试。类型只能保证“形状”正确,不能保证“逻辑”正确。比如一个转账接口,类型上amount: number没问题,但业务逻辑里忘了检查余额是否充足,类型系统完全看不出来。
我的测试策略是分层的:
| 测试层级 | 测什么 | 工具 | 频率 |
|---|---|---|---|
| 类型检查 | 类型契约是否一致 | tsc | 每次提交 |
| 单元测试 | 纯函数、工具函数 | Vitest | 每次提交 |
| 集成测试 | tRPC procedure 的输入输出 | Vitest + 测试数据库 | 每次 PR |
| 端到端测试 | 真实浏览器里的用户流程 | Playwright | 每日构建 |
其中集成测试是 t3code 项目里性价比最高的。因为 tRPC 的 procedure 是纯函数式的(输入确定、输出确定),你可以直接调用caller来测试,不需要启动 HTTP 服务器。比如:
const caller = appRouter.createCaller({ prisma, session: mockSession }); const result = await caller.user.getProfile({ id: 'test-id' }); expect(result.name).toBe('Test User');这种测试跑得飞快,而且能覆盖到 Zod 校验、中间件、数据库查询的完整链路。我通常会把核心业务逻辑的 procedure 都写一遍集成测试,确保重构时不会破坏行为。
6. 关于 t3code 的一些个人体会
写了这么多,最后聊点务实的。t3code 这套东西不是银弹,它有明确的适用边界。如果你的团队里有人不熟悉 TypeScript,或者后端必须用其他语言,那强行上 tRPC 只会增加沟通成本。我见过一个项目,后端是 Python,前端是 React,团队为了“类型安全”硬生生用 OpenAPI 生成 TypeScript 类型,结果每次后端改接口都要重新生成、手动合并,比手写类型还累。这种情况下,老老实实用 REST + 手写类型 + 契约测试,可能更务实。
另外,t3code 的学习曲线主要集中在 tRPC 和 Prisma 的配合上。如果你之前没用过 Prisma,建议先花半天把它的 schema 语法和 client 用法过一遍,再来看 tRPC 的集成。否则你会分不清哪些类型是 Prisma 给的,哪些是 tRPC 推导的,出了问题也不知道该查哪一层。
我在实际使用中最大的感受是:类型安全带来的最大收益不是“少写 bug”,而是“敢改代码”。以前改一个字段名要犹豫半天,现在改完跑一下类型检查,错误列表就是我的待办清单。这种心理上的轻松感,比省下几个调试小时更值钱。当然,前提是你得把类型检查真正跑起来,而不是只在编辑器里看看。