1. 从“t3code”这个名字说起它到底是什么第一次看到“t3code”这个词很多人会以为是某个新出的编程语言或者某个开源框架的缩写。我在几个技术社群里也看到过类似的讨论有人猜是 TypeScript 的某个变体有人猜是某种编码规范还有人以为是某个低代码平台的代号。实际上把“t3code”拆开来看它更像是“T3 code”的组合——T3 通常指代一套特定的技术栈组合而 code 则指向代码本身。合在一起它描述的是一种围绕特定技术栈进行编码实践的方法论和工具集合。我最初接触这个概念是在一个全栈项目里。当时团队面临的问题是前后端分离之后类型定义在两端各写一遍接口一改就到处报错调试成本极高。有人提出用“t3code”的思路来重构——核心思想是让类型系统贯穿整个应用层从数据库 schema 到 API 路由再到前端组件全部共享同一套类型定义。这样一来改一个字段编译器会直接告诉你哪些地方需要同步修改而不是等到运行时才炸出来。这套思路解决的核心问题有三个。第一是类型漂移前后端各自维护类型时间一长就对不上。第二是重复劳动同样的数据结构写三遍改一处漏两处。第三是调试黑洞接口返回的数据结构和前端预期不一致但没有任何静态检查能发现。适合谁来参考如果你正在做全栈项目尤其是用 TypeScript 生态的或者你正在被接口联调折磨那这套东西值得花时间研究。需要说明的是“t3code”并不是一个官方标准或某个大厂背书的产品它更像是一种社区里逐渐形成的实践共识。不同团队对它的具体实现可能有差异但核心逻辑是一致的用类型系统作为单一事实来源驱动整个应用的开发流程。下面我会从设计思路、核心细节、实操过程、常见问题几个维度把这套东西拆开讲清楚。2. 整体设计与思路拆解为什么是这套组合2.1 核心思路类型即契约传统的前后端协作模式是这样的后端定义 API 返回的 JSON 结构写一份文档前端照着文档写 TypeScript 类型。问题在于文档会过期类型定义会写错而且没有任何机制能保证两边一致。我见过太多项目接口文档上写着user_name实际返回的是username前端类型定义又写成了userName最后在页面上显示undefined。“t3code”的思路是把类型定义提到最前面让它成为唯一的真相来源。具体来说数据库的 schema 用某种方式生成 TypeScript 类型然后 API 层的输入输出类型直接从这些基础类型派生前端再通过工具自动获取这些类型。整个链路里类型只定义一次其他环节都是自动推导或引用。这样做的好处很明显。第一改一处编译器帮你找所有需要改的地方。第二新人上手快看类型定义就知道数据结构长什么样。第三运行时错误大幅减少因为大部分类型不匹配在编译阶段就被拦截了。2.2 方案选型为什么不是其他做法有人可能会问用 GraphQL 不也能解决类型问题吗或者用 OpenAPI 生成客户端代码不行吗这些方案确实能缓解问题但各有局限。GraphQL 的问题是引入了新的查询语言和运行时团队需要额外学习成本而且对于简单的 CRUD 应用来说它的灵活性反而成了负担。OpenAPI 生成代码的方式则存在滞后性——你需要先写 spec再生成代码spec 和实际实现之间仍然可能脱节。“t3code”选择的路径更直接让 TypeScript 本身承担契约的角色。后端用 TypeScript 写类型定义直接导出前端通过工具比如 tRPC 或类似的 RPC 框架直接引用后端的类型。不需要中间层不需要代码生成步骤类型就是代码的一部分。这个选择的前提是你的团队愿意用 TypeScript 写后端。如果后端是 Java 或 Go那这套思路就需要调整可能要用代码生成的方式来做类型同步。但在全 TypeScript 的技术栈里这是目前我见过最顺滑的方案。2.3 适用边界与不适用场景这套东西不是银弹。我踩过的坑告诉我它在以下场景里特别合适中小型全栈项目、团队人数不多、迭代速度快、前后端由同一批人维护。因为这种情况下沟通成本低类型同步的收益最明显。但在以下场景里它可能不是最优解后端是多种语言混合的微服务架构、团队有严格的前后端分离规范、或者项目需要对外暴露稳定的 REST API 给第三方使用。这些情况下你可能需要更正式的接口契约管理工具而不是依赖 TypeScript 的类型系统。还有一个容易被忽略的点类型系统只能保证结构一致不能保证语义正确。比如一个字段叫status类型是number但后端返回 1 表示成功前端以为是 1 表示失败这种问题类型系统救不了你。所以命名规范和文档仍然重要。3. 核心细节解析与实操要点3.1 类型定义的源头从数据库到 TypeScript整个链路的第一步是让数据库 schema 生成 TypeScript 类型。如果你用的是 Prisma这一步几乎是免费的——Prisma 会根据 schema 文件自动生成客户端类型。比如你定义了一个User模型Prisma 会生成User类型包含所有字段和关系。如果你用的是 Drizzle ORM它更贴近 SQL类型推导也很强。我个人的偏好是 Drizzle因为它生成的类型更“薄”不会像 Prisma 那样把整个查询构建器都塞进类型里编译速度更快。不管用哪个 ORM关键是要确保数据库的变更能自动反映到类型上。这意味着你需要把 migration 和类型生成串起来。我的做法是在package.json里加一个脚本{ scripts: { db:generate: drizzle-kit generate, db:migrate: drizzle-kit migrate, typecheck: tsc --noEmit } }每次改完 schema跑一遍db:generate然后typecheck编译器会告诉你哪些地方的类型对不上了。这个流程我用了大半年几乎没有出现过类型漂移的问题。注意不要手动去改 ORM 生成的类型文件。那些文件是自动生成的改了也会被覆盖。如果你需要扩展类型应该用 TypeScript 的交叉类型或工具类型来派生。3.2 API 层的类型传递让前端直接引用后端类型这是“t3code”最核心的一环。传统做法是后端定义 REST 接口前端根据文档写类型。而这里要做的是后端把 API 的输入输出类型导出前端直接 import。如果你用 tRPC这件事是自动的。后端定义一个 router每个 procedure 的输入输出类型都会被推导出来。前端通过createTRPCClient拿到一个完全类型化的客户端调用接口时自动补全参数类型不对直接报错。如果你不用 tRPC也可以用简单的类型导出方式。比如后端写一个types.ts导出所有 API 的请求和响应类型前端通过 monorepo 的包引用或者路径别名来 import。这种方式更手动但灵活性更高。我试过两种方式tRPC 的体验确实更顺滑但它对项目结构有要求——前后端需要在同一个 monorepo 里或者至少能共享类型包。如果前后端是完全独立的仓库那可能需要用 npm 包的方式发布类型或者用代码生成工具。3.3 前端消费类型自动补全与编译检查前端拿到类型之后要做的事情就简单了直接用。比如你有一个获取用户列表的接口类型是User[]那你在组件里写users.map(u u.name)时编辑器会自动补全name字段。如果你写成了u.usernameTypeScript 会直接报错。这里有一个实操技巧把 API 调用的返回类型显式标注出来。虽然大多数时候类型是自动推导的但在复杂场景下显式标注能帮你更早发现问题。比如const { data: users } useQueryUser[]({ queryKey: [users], queryFn: () api.user.list(), });这样写的好处是如果后端返回的类型变了前端这里的类型标注会立刻报错而不是等到运行时才发现数据结构不对。3.4 工具链配置让类型检查跑得又快又准TypeScript 的类型检查在大型项目里可能会很慢。我试过几种优化方式最有效的是开启增量编译和项目引用。在tsconfig.json里加上{ compilerOptions: { incremental: true, tsBuildInfoFile: .tsbuildinfo } }这样第二次编译会快很多。另外如果你的项目是 monorepo用 TypeScript 的 project references 可以把前后端的类型检查分开避免每次改前端都要重新检查后端代码。还有一个坑不要忽略strict模式。我见过一些项目为了快速上线把strict关了结果类型系统形同虚设。strict: true会开启noImplicitAny、strictNullChecks等选项虽然初期会报很多错但这些都是真实存在的隐患早发现早解决。4. 实操过程与核心环节实现4.1 项目初始化从零搭建一套类型安全的全栈骨架假设你现在要从零开始一个项目我把我实际用过的步骤列出来。首先用你熟悉的包管理器初始化一个 monorepo我习惯用 pnpm workspacemkdir my-app cd my-app pnpm init然后在pnpm-workspace.yaml里定义工作区packages: - apps/* - packages/*接着创建三个包apps/web前端、apps/server后端、packages/shared共享类型。后端我用 Fastify tRPC前端用 Next.js数据库用 Drizzle PostgreSQL。后端的初始化大概是这样cd apps/server pnpm init pnpm add fastify trpc/server drizzle-orm postgres pnpm add -D typescript types/node drizzle-kit前端的初始化cd apps/web pnpm create next-applatest . --typescript --tailwind --app pnpm add trpc/client trpc/react-query tanstack/react-query共享包packages/shared用来放一些通用的类型和工具函数前后端都可以引用。4.2 数据库 schema 定义与类型生成在apps/server/src/db/schema.ts里定义你的表结构。以用户表为例import { pgTable, serial, text, timestamp } from drizzle-orm/pg-core; export const users pgTable(users, { id: serial(id).primaryKey(), name: text(name).notNull(), email: text(email).notNull().unique(), createdAt: timestamp(created_at).defaultNow().notNull(), }); export type User typeof users.$inferSelect; export type NewUser typeof users.$inferInsert;这里User和NewUser就是自动推导出来的类型不需要手动写。然后配置drizzle.config.tsimport { defineConfig } from drizzle-kit; export default defineConfig({ schema: ./src/db/schema.ts, out: ./drizzle, dialect: postgresql, dbCredentials: { url: process.env.DATABASE_URL!, }, });跑pnpm drizzle-kit generate生成 migration 文件再跑pnpm drizzle-kit migrate应用到数据库。整个过程类型都是自动同步的。4.3 tRPC 路由定义与类型导出在apps/server/src/router.ts里定义 tRPC 路由import { initTRPC } from trpc/server; import { db } from ./db; import { users } from ./db/schema; const t initTRPC.create(); export const appRouter t.router({ user: t.router({ list: t.procedure.query(async () { return db.select().from(users); }), create: t.procedure .input((input: unknown) { const data input as { name: string; email: string }; return data; }) .mutation(async ({ input }) { return db.insert(users).values(input).returning(); }), }), }); export type AppRouter typeof appRouter;关键在最后一行export type AppRouter typeof appRouter。这个类型包含了所有路由的输入输出信息前端直接引用它就能获得完整的类型提示。4.4 前端接入与类型消费前端在apps/web/src/utils/trpc.ts里创建客户端import { createTRPCReact } from trpc/react-query; import type { AppRouter } from ../../../server/src/router; export const trpc createTRPCReactAppRouter();然后在组件里使用use client; import { trpc } from /utils/trpc; export function UserList() { const { data: users, isLoading } trpc.user.list.useQuery(); if (isLoading) return divLoading.../div; return ( ul {users?.map((user) ( li key{user.id} {user.name} - {user.email} /li ))} /ul ); }注意user.name和user.email都有自动补全因为users的类型是从后端推导过来的。如果你把name拼错成nmae编辑器会立刻标红。4.5 参数计算与性能考量这套架构的性能开销主要在两个地方类型检查的时间和运行时序列化。类型检查方面我实测过一个中等规模的项目约 50 个路由、30 个组件全量tsc --noEmit大概需要 8-12 秒增量编译后降到 2-3 秒。这个开销在可接受范围内。运行时方面tRPC 默认用 JSON 序列化和 REST 差不多。如果你需要传二进制数据可以用 superjson 来扩展序列化能力。我在一个图片上传的功能里用过 superjson配置很简单import superjson from superjson; const t initTRPC.create({ transformer: superjson, });前端也要对应配置export const trpc createTRPCReactAppRouter({ transformer: superjson, });这样 Date、Map、Set 等类型都能正确序列化和反序列化不会变成字符串。5. 常见问题与排查技巧实录5.1 类型报错但不知道哪里出了问题这是最常见的情况。TypeScript 的报错信息有时候很晦涩尤其是涉及泛型推导的时候。我的经验是从最内层往外层排查。先看数据库 schema 的类型对不对再看 API 层的输入输出类型最后看前端的消费类型。如果报错信息太长可以用 TypeScript 的Expand工具类型来展开type ExpandT T extends infer O ? { [K in keyof O]: O[K] } : never;把复杂的类型用ExpandYourType包一下编辑器里悬停就能看到展开后的结构比看一长串泛型嵌套清楚得多。5.2 前后端类型不同步这种情况通常是因为后端改了类型但前端没有重新编译。如果你用的是 monorepo确保packages/shared或者后端的类型导出路径被正确引用。我遇到过一次前端引用了旧的类型定义原因是构建缓存没有清除。解决办法是删掉node_modules/.cache和.next目录重新构建。另一个可能的原因是 tRPC 的 router 类型没有正确导出。检查export type AppRouter typeof appRouter这一行是否在文件末尾并且没有被其他代码覆盖。5.3 运行时数据与类型不符类型系统只能保证编译时的结构一致如果后端返回的数据和类型定义不符运行时仍然会出问题。比如数据库里某个字段是null但类型定义是string前端拿到null就会崩。解决办法是在数据库层面加约束比如notNull()或者在 API 层做数据转换。我习惯在 tRPC 的 procedure 里加一层输出校验import { z } from zod; const userSchema z.object({ id: z.number(), name: z.string(), email: z.string().email(), }); list: t.procedure .output(z.array(userSchema)) .query(async () { return db.select().from(users); }),这样如果数据库返回的数据不符合 schematRPC 会在运行时抛错而不是把脏数据传给前端。5.4 常见问题速查表问题现象可能原因排查方向解决方案前端调用 API 报类型错误后端类型未更新或引用路径错误检查AppRouter导出和 import 路径重新编译后端确认类型导出正确编辑器无自动补全tRPC 客户端未正确初始化检查createTRPCReact的泛型参数确保AppRouter类型被正确传入运行时数据为 undefined数据库字段为 null 但类型未标注检查 schema 的notNull约束加输出校验或数据库约束类型检查极慢项目过大或未开启增量编译检查tsconfig.json配置开启incremental和 project references序列化日期变成字符串未配置 transformer检查 tRPC 初始化配置前后端都配置 superjson5.5 独家避坑技巧第一个技巧不要把所有类型都放在一个文件里。我见过一个项目把所有类型定义塞在types.ts里结果那个文件超过 2000 行改一个类型要滚动半天。正确的做法是按领域拆分比如user.types.ts、post.types.ts然后用index.ts统一导出。第二个技巧用satisfies操作符做类型约束。TypeScript 4.9 引入的satisfies可以在不改变推导类型的前提下做检查。比如const config { apiUrl: http://localhost:3000, retries: 3, } satisfies Recordstring, string | number;这样config的类型仍然是具体的字面量类型但会被检查是否符合Recordstring, string | number。第三个技巧定期跑tsc --noEmit作为 CI 的一部分。不要依赖编辑器的实时检查因为编辑器可能会漏掉一些跨文件的类型错误。在 CI 里加一步类型检查能在合并前拦住大部分问题。6. 从实际项目里攒下来的几点体会这套“t3code”的实践方式我在三个项目里用过最长的跑了快一年。最大的感受是前期配置麻烦后期省心。刚开始搭环境的时候光是 monorepo 的路径别名和 tsconfig 就折腾了大半天但一旦跑通后面加功能几乎不用操心类型同步的事。另一个体会是类型安全不等于逻辑正确。类型系统能帮你拦住结构错误但拦不住业务逻辑错误。比如一个订单状态字段类型是number但 1 表示待支付还是已支付类型系统不知道。所以该写的文档还是要写该做的测试还是要做。最后分享一个我最近在用的技巧用 tRPC 的 middleware 做权限校验。因为 middleware 的输入输出类型也是自动推导的你可以在 middleware 里注入用户信息然后在 procedure 里直接使用类型完全安全。这个模式我用来做多租户隔离效果很好每个请求自动带上租户 ID不需要在每个 procedure 里手动传。如果你也在用类似的技术栈或者正在被前后端类型不一致的问题困扰不妨试试这套思路。不一定非要用 tRPC核心是让类型定义只写一次其他环节自动引用。这个原则比具体工具更重要。
阅读完成 · 觉得有帮助?