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里只放用户相关的类型和 schemaorder.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.infertypeof 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.yamlpackages: - 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里定义 schemamodel 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 createTRPCReactAppRouter();然后在根组件里挂 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 不通过类型检查根本不让合并省了很多事后排查的时间。