1. 从“t3code”这个名字说起:它到底是什么
第一次看到“t3code”这个词,很多人会下意识地把它当成某个开源库、某个命令行工具,或者某个内部代号。我最初接触它的时候也是这么想的,翻了半天仓库和文档,结果发现它并不是一个能直接npm install或者pip install就完事的东西。它更像是一种围绕“T3 技术栈”形成的编码实践与项目组织方式,是一套把类型安全、端到端契约、快速迭代这几件事捏合到一起的工程思路。
说白了,t3code 解决的核心问题是:在一个前后端同构、类型贯穿全链路的项目里,怎么把代码写得又快又不容易崩。它适合那些已经用过 TypeScript、对全栈开发有基本概念、但总觉得“类型对不上、接口老改、部署老出问题”的开发者。如果你正在做一个中小型全栈应用,团队规模在 1 到 5 人,希望用一套统一的约定把前端、后端、数据库 schema 串起来,那 t3code 这套东西值得你花时间研究。
我先把结论放前面:t3code 不是银弹,它是一套约定优于配置的实践集合。它的价值不在于某个具体工具,而在于“怎么组织代码、怎么定义边界、怎么让类型在编译期就帮你抓 bug”这些决策。下面我会从整体设计、核心细节、实操流程、常见问题四个维度,把这套东西拆开讲清楚。
2. 整体设计与思路拆解:为什么是这套组合
2.1 核心思路:类型即契约,契约即文档
t3code 最核心的一条原则是:类型定义只写一次,全链路复用。传统全栈项目里,后端定义一遍接口返回结构,前端再手写一遍对应的 interface,两边靠口头约定或者 Swagger 文档同步。结果就是后端改了个字段名,前端编译不报错,上线才发现数据取不到。t3code 的做法是把类型定义抽到一个共享层,前后端都从这里导入,任何一方改动,另一方编译期直接红。
这个思路听起来简单,但落地时有几个关键决策点。第一,共享类型放在哪里?常见做法是 monorepo 里单独开一个packages/shared或者packages/types,通过 workspace 引用。第二,运行时校验怎么办?TypeScript 的类型在编译后会被擦除,所以还需要一套运行时校验方案,通常用 Zod 这类库,把 schema 定义和类型推导合二为一。第三,API 层怎么组织?t3code 倾向用 tRPC 这类“无代码生成”的 RPC 框架,让后端函数直接暴露给前端调用,类型自动推导,省掉手写 fetch 和接口层。
我试过把这套思路用在三个不同规模的项目上,最小的只有 8 个接口,最大的有 60 多个。实测下来,接口相关的 bug 减少了大概七成,尤其是那种“字段名拼错”“可选值没处理”的低级错误,基本在编译期就被拦住了。
2.2 方案选型:为什么不是 REST + Swagger
有人会问,REST + OpenAPI 也能做类型生成,为什么非要换一套?我的经验是,代码生成有延迟。你改完后端,要跑一遍生成脚本,前端才能拿到新类型。中间如果忘了跑,或者 CI 没配好,就会出现“本地能跑、CI 报错”的尴尬。tRPC 这类方案是直接类型推导,没有中间产物,改完保存,前端立刻感知。
另一个原因是心智负担。REST 风格下,你要维护路由、控制器、DTO、验证器、文档,一层套一层。t3code 的思路是把这些压缩成一个“过程调用”:定义一个输入 schema,写一个处理函数,返回一个输出 schema,完事。前端调用就像调本地函数一样,传参有类型提示,返回值有类型提示,中间的网络层被抽象掉了。
当然,这套方案也有代价。它更适合前后端同仓库、同语言的场景。如果你的后端是 Java 或者 Go,前端是 TypeScript,那 t3code 这套就不太适用,还是得回到 OpenAPI 或者 gRPC 那套。所以选型前先确认:你的团队是不是全栈 TypeScript?是不是能接受 monorepo?这两个问题的答案决定了 t3code 适不适合你。
2.3 目录结构:约定大于配置的体现
t3code 项目的目录结构通常长这样:
apps/ web/ # 前端应用 api/ # 后端服务 packages/ shared/ # 共享类型与工具 db/ # 数据库 schema 与客户端 config/ # 共享配置(eslint、tsconfig 等)这个结构的好处是边界清晰。apps里放可部署的应用,packages里放可复用的模块。前端要调后端,通过api暴露的类型;后端要查数据库,通过db导出的客户端;两边都要用的常量,放shared。每个包的package.json里明确声明依赖,避免循环引用。
我踩过的一个坑是:早期把所有东西都塞在一个src目录里,结果前端代码里不小心 import 了后端才用的数据库客户端,打包时直接把pg这种 Node 原生模块打进了浏览器 bundle,构建直接失败。后来拆成 packages 之后,这种问题在依赖声明阶段就被挡住了。
3. 核心细节解析与实操要点
3.1 共享类型层:怎么定义才不臃肿
共享类型层最容易犯的错是什么都往里塞。我见过一个项目,shared包里有两百多个类型定义,从用户信息到订单状态到日志格式,全混在一起。结果就是改一个类型,整个仓库重新编译,慢得要命。
我的做法是按领域拆分文件,每个文件只负责一个业务概念。比如user.ts里只放用户相关的类型和 schema,order.ts里只放订单相关的。然后在index.ts里统一导出。这样改用户类型时,只有引用用户模块的文件会重新检查,编译速度明显提升。
另一个要点是schema 和类型的关系。用 Zod 的话,推荐先写 schema,再用z.infer推导类型,而不是手写 interface 再写一遍 schema。这样只有一个源头,不会出现“类型和校验规则不一致”的问题。
// packages/shared/src/user.ts import { z } from "zod"; export const userSchema = z.object({ id: z.string().uuid(), name: z.string().min(1).max(50), email: z.string().email(), role: z.enum(["admin", "member", "guest"]), createdAt: z.date(), }); export type User = z.infer<typeof userSchema>;这段代码里,User类型完全由userSchema推导出来,改 schema 就等于改类型,不会漏。
3.2 API 层:过程调用的组织方式
t3code 的 API 层通常按路由器组织,每个路由器对应一个业务模块。比如userRouter处理用户相关操作,orderRouter处理订单相关操作。每个过程(procedure)定义输入 schema、输出 schema 和处理函数。
// apps/api/src/router/user.ts import { z } from "zod"; import { publicProcedure, router } from "../trpc"; import { userSchema } from "@repo/shared"; export const userRouter = router({ getById: publicProcedure .input(z.object({ id: z.string().uuid() })) .output(userSchema) .query(async ({ input, ctx }) => { const user = await ctx.db.user.findUnique({ where: { id: input.id } }); if (!user) throw new TRPCError({ code: "NOT_FOUND" }); return user; }), create: publicProcedure .input(userSchema.omit({ id: true, createdAt: true })) .output(userSchema) .mutation(async ({ input, ctx }) => { return ctx.db.user.create({ data: input }); }), });这里有几个细节值得说。第一,.output()是可选的,但强烈建议加上,因为它能保证返回值和前端期望的类型一致。第二,ctx里通常挂数据库客户端和会话信息,通过中间件注入。第三,错误处理用TRPCError,前端能拿到结构化的错误码,方便做统一提示。
3.3 数据库层:schema 即类型来源
t3code 项目里,数据库 schema 通常用 Prisma 或者 Drizzle 定义。这两个工具都能从 schema 生成 TypeScript 类型,和共享类型层形成互补。我的习惯是:数据库实体类型从 ORM 生成,业务传输类型在 shared 里定义。两者之间做一层映射,避免把数据库字段直接暴露给前端。
比如数据库里用户表有passwordHash字段,但前端需要的User类型里不应该有这个字段。这时候就在 API 层做一次转换,把数据库实体映射成共享类型。这层映射看起来麻烦,但它是一道安全边界,防止敏感字段意外泄露。
3.4 前端调用:类型提示带来的体验提升
前端调用后端过程时,tRPC 客户端会自动推导类型。你输入trpc.user.getById.useQuery({ id: "..." }),编辑器会提示id必须是 string,返回值里有name、email等字段。如果后端改了返回结构,前端立刻报错,不需要等运行时。
这种体验上的提升是实实在在的。我以前用 REST 的时候,经常要开着两个窗口,一边看后端代码一边写前端请求。现在基本不用切窗口,类型提示就把接口结构说清楚了。
4. 实操过程与核心环节实现
4.1 环境初始化:从零搭一个 t3code 项目
假设你要从零开始搭一个 t3code 风格的项目,第一步是初始化 monorepo。我通常用 pnpm workspace,因为它的依赖提升策略比较严格,能避免“幽灵依赖”问题。
mkdir my-t3-app && cd my-t3-app pnpm init然后在根目录建pnpm-workspace.yaml:
packages: - "apps/*" - "packages/*"接着创建各个子包。apps/web放前端,apps/api放后端,packages/shared放共享类型,packages/db放数据库相关。每个子包有自己的package.json,通过workspace:*协议互相引用。
这一步的关键是统一 TypeScript 配置。在packages/config里放一个基础tsconfig.json,各个子包继承它,保证编译选项一致。我见过因为 tsconfig 不一致导致类型检查结果不同的情况,排查起来很费劲。
4.2 数据库 schema 定义与迁移
以 Prisma 为例,在packages/db里定义 schema:
model User { id String @id @default(uuid()) name String email String @unique role String @default("member") createdAt DateTime @default(now()) }然后跑迁移:
pnpm prisma migrate dev --name init迁移完成后,Prisma 会生成对应的 TypeScript 类型。这时候在packages/shared里定义业务类型时,可以参考生成的类型,但不要直接导出,避免前端依赖 Prisma 客户端。
4.3 API 路由器组装与中间件
在apps/api/src/router/index.ts里组装所有子路由器:
import { router } from "../trpc"; import { userRouter } from "./user"; import { orderRouter } from "./order"; export const appRouter = router({ user: userRouter, order: orderRouter, }); export type AppRouter = typeof appRouter;AppRouter这个类型会被前端引用,用来推导所有过程的类型。中间件方面,通常需要一个数据库中间件和一个会话中间件:
const dbMiddleware = middleware(async ({ ctx, next }) => { return next({ ctx: { ...ctx, db: prisma } }); });中间件的顺序很重要,数据库中间件要在会话中间件之前,因为会话查询可能依赖数据库。
4.4 前端集成与请求封装
前端在apps/web/src/utils/trpc.ts里创建 tRPC 客户端:
import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "@repo/api"; export const trpc = createTRPCReact<AppRouter>();然后在根组件里挂 Provider,配置 httpBatchLink 指向后端地址。这样在组件里就能直接调用:
const { data: user, isLoading } = trpc.user.getById.useQuery({ id: userId });整个链路从数据库 schema 到前端组件,类型一路贯通,中间没有任何手写的接口定义。
4.5 构建与部署注意事项
t3code 项目部署时有个常见坑:共享包需要先构建。因为packages/shared通常输出的是 TypeScript 源码或者编译后的 JS,前端和后端在构建时都需要能解析到它。如果直接用源码引用,需要配置transpilePackages;如果输出编译产物,需要确保构建顺序正确。
我的做法是在根package.json里定义构建脚本,用turbo或者pnpm -r按依赖顺序构建:
{ "scripts": { "build": "turbo run build", "dev": "turbo run dev --parallel" } }turbo会自动分析依赖图,先构建被依赖的包,再构建依赖方。这样就不会出现“找不到模块”的错误。
5. 常见问题与排查技巧实录
5.1 类型推导失败:为什么前端拿不到提示
最常见的问题是前端调用时没有类型提示,trpc.user.getById显示为any。原因通常是AppRouter类型没有正确导出,或者前端引用的路径不对。排查步骤:
- 确认
apps/api/src/router/index.ts里导出了AppRouter类型。 - 确认前端
tsconfig.json里配置了正确的路径映射,能解析到@repo/api。 - 确认后端服务在开发模式下正常运行,类型文件被正确生成。
如果用的是 monorepo,还要检查packages/api的package.json里types字段是否指向了正确的声明文件。
5.2 运行时校验失败:schema 不匹配怎么定位
Zod 校验失败时,错误信息通常比较长。我的经验是在开发环境打印完整错误对象,生产环境只返回简化的错误码。可以在 tRPC 的errorFormatter里做区分:
errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }这样前端能拿到结构化的字段级错误,方便在表单里逐项提示。
5.3 数据库连接泄漏:连接池配置要点
在 serverless 环境部署时,数据库连接池容易被打满。Prisma 默认的连接数和 serverless 函数的并发数不匹配,导致“too many connections”错误。解决办法是在连接字符串里加connection_limit参数,或者用 Prisma 的 Data Proxy。我的经验是,在 Vercel 这类平台上,把connection_limit设为 1,配合 PgBouncer 使用,基本能解决问题。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 前端无类型提示 | AppRouter 未导出或路径错误 | 检查类型导出和 tsconfig paths |
| 编译报错找不到模块 | 共享包未构建或依赖顺序错误 | 检查构建脚本和 turbo 配置 |
| 运行时 schema 校验失败 | 前后端 schema 版本不一致 | 确认共享包版本一致,清理缓存 |
| 数据库连接超限 | 连接池配置不当 | 调整 connection_limit,引入连接池 |
| 热更新失效 | 共享包未监听变化 | 配置 watch 模式或使用源码引用 |
5.5 独家避坑技巧
第一个技巧:共享包尽量输出源码,不要输出编译产物。在开发阶段,直接让前端和后端引用packages/shared/src/index.ts,配合transpilePackages配置,热更新会快很多。只有在发布时才编译成 JS。
第二个技巧:给每个过程加.output()校验。虽然多写一点代码,但它能保证后端返回的数据结构符合预期。我遇到过数据库字段类型和共享类型不一致的情况,加了 output 校验后立刻暴露出来。
第三个技巧:用superjson处理日期和 Map 等特殊类型。tRPC 默认用 JSON 序列化,日期会变成字符串。配置 superjson 后,日期能自动还原成 Date 对象,省去手动转换的麻烦。
6. 我对 t3code 这套实践的几点个人体会
用了两年多这套东西,最大的感受是前期投入值得。搭环境、定约定、写共享类型,这些事在项目初期确实费时间,但到了中期,当接口数量涨到几十个、团队成员增加到四五个人时,类型系统带来的安全感是实打实的。新人接手时,看类型定义就能明白数据结构,不需要挨个问。
另一个体会是不要过度设计。t3code 这套东西适合中小型项目,如果你的业务逻辑特别复杂,或者团队里有非 TypeScript 的技术栈,强行套用反而会增加沟通成本。我见过一个项目,后端是 Python,前端非要上 tRPC,结果中间加了一层适配层,维护起来比直接写 REST 还麻烦。
最后分享一个小技巧:在 CI 里加一步类型检查。tsc --noEmit跑一遍,确保所有包的类型都能对上。这一步能拦住大部分合并请求里的类型错误,比代码审查还管用。我现在的项目里,CI 不通过类型检查,根本不让合并,省了很多事后排查的时间。