1. 从“t3code”这个标题说起:它到底是什么
第一次看到“t3code”这个词,很多人会一头雾水。它不像“Vue3”或“Python3”那样有明确的版本号指向,也不像“TypeScript”那样有官方文档背书。但如果你在开发者社区里泡过一段时间,就会发现“t3code”其实是一个在技术圈里口口相传的代号,它指向的是一套围绕TypeScript 全栈开发的实践体系,核心思路是用一套语言、一套类型定义,把前端、后端、数据库操作全部串起来。
我最早接触这个概念是在一个中小型 SaaS 项目的重构里。当时团队面临的问题很典型:前端用 React,后端用 Node.js,数据库是 PostgreSQL,但前后端之间的接口类型全靠手写.d.ts文件维护,每次改一个字段就要两边同步,漏改一次就是线上事故。后来有人提出“能不能让类型定义只写一次,前后端自动共享”,于是我们开始研究 t3code 这套思路。它并不是一个具体的框架或库,而是一种开发范式——以 TypeScript 为核心,借助 tRPC、Prisma、Next.js 等工具链,实现端到端的类型安全。
这套东西能解决什么问题?简单说,它把“接口联调”这个环节从开发流程里几乎抹掉了。前端调用后端 API 时,参数类型、返回值类型、错误类型全部由 TypeScript 编译器自动校验,写错一个字段名,编辑器立刻标红。对于中小团队来说,这意味着不需要再维护 OpenAPI 文档,不需要写接口 mock,也不需要专门的人去对齐前后端字段。适合谁来参考?我认为有三类人最值得看:一是正在做全栈项目但被接口类型折磨的开发者;二是想从 JavaScript 迁移到 TypeScript 但不知道如何落地的团队;三是独立开发者,想用一套技术栈快速把产品跑起来。
但要注意,t3code 不是银弹。它对项目的规模、团队的 TypeScript 熟练度、部署环境都有一定要求。后面我会详细拆解它的适用边界和踩坑经验。
2. 核心思路拆解:为什么是“一套类型走天下”
2.1 传统全栈开发的类型断裂问题
在传统的全栈开发模式里,前后端之间的类型传递是靠“约定”和“文档”来维持的。后端定义一个User对象,有id、name、email三个字段;前端也定义一个User接口,同样三个字段。看起来没问题,但实际开发中,后端加了一个avatarUrl字段,前端不知道,页面就不显示头像;后端把id从number改成string,前端还在用number接收,运行时直接报错。
这种问题的根源在于:类型定义是分散的,没有单一事实来源。每个开发者都在自己的代码里维护一份“影子类型”,靠人工同步。项目小的时候还能靠 code review 发现,项目一大,接口一多,就彻底失控。
t3code 的核心思路就是消灭这种分散。它让后端成为类型的“生产者”,前端成为类型的“消费者”,中间通过 TypeScript 的类型推导自动传递。具体来说,后端用 tRPC 定义 procedure,每个 procedure 的输入和输出类型都是显式声明的;前端通过 tRPC 的 client 调用这些 procedure,TypeScript 会自动推导出参数和返回值的类型。整个过程不需要手写任何接口类型。
2.2 为什么选 tRPC 而不是 REST 或 GraphQL
这里要解释一个关键选型问题:为什么 t3code 体系里通常用 tRPC 而不是 REST 或 GraphQL?
REST 的问题是它没有类型系统。你定义一个POST /api/user,请求体是什么类型、返回什么类型,全靠文档和约定。虽然可以用 OpenAPI 生成类型,但那是“事后生成”,不是“源头共享”。GraphQL 有 schema,类型是强制的,但它的 schema 是独立于 TypeScript 的一门语言,需要代码生成步骤,而且查询语句的灵活性反而增加了前端复杂度。
tRPC 的优势在于:它本身就是 TypeScript。你写一个 procedure,就像写一个普通函数,输入输出类型直接写在函数签名里。前端调用时,TypeScript 编译器直接读取后端的类型定义,不需要任何中间层。这种“零代码生成”的体验,是 t3code 体系最吸引人的地方。
当然,tRPC 也有局限。它要求前后端都是 TypeScript,而且通常部署在同一个 monorepo 里。如果后端是 Java 或 Go,那就用不了。所以 t3code 更适合全栈 TypeScript 团队,而不是前后端分离的大型组织。
2.3 Prisma 在其中的角色:数据库类型也要统一
t3code 的另一个关键组件是 Prisma。Prisma 是一个 TypeScript 优先的 ORM,它的核心特点是:数据库 schema 用 Prisma Schema 定义,然后自动生成 TypeScript 类型。这意味着从数据库表结构到后端查询,再到前端展示,整条链路的类型都是打通的。
举个例子。你在schema.prisma里定义一个User模型:
model User { id String @id @default(cuid()) name String email String @unique avatarUrl String? createdAt DateTime @default(now()) }Prisma 会自动生成一个User类型,包含id: string、name: string、email: string、avatarUrl: string | null、createdAt: Date。后端用 Prisma Client 查询时,返回的就是这个类型。然后 tRPC 的 procedure 直接返回这个类型,前端调用时拿到的也是这个类型。整个过程没有任何手写类型定义。
这种“数据库到 UI”的类型贯通,是 t3code 体系最强大的地方。它让“改字段”这件事变得极其安全:你改 Prisma Schema,重新生成类型,TypeScript 编译器会告诉你哪些地方需要同步修改。漏改?不存在的。
3. 实操环境搭建:从零开始跑通一个 t3code 项目
3.1 初始化项目与依赖选择
假设你要从零开始搭一个 t3code 风格的项目。第一步是初始化 monorepo。我推荐用pnpm作为包管理器,因为它对 workspace 的支持最成熟。目录结构大概是这样:
my-t3-app/ ├── apps/ │ ├── web/ # Next.js 前端 │ └── api/ # tRPC 后端(也可以合并到 web 里) ├── packages/ │ ├── db/ # Prisma schema 和 client │ └── shared/ # 共享工具函数和类型 ├── package.json └── pnpm-workspace.yaml为什么用 monorepo?因为 t3code 的核心是类型共享,如果前后端在不同仓库,TypeScript 的类型推导就断了。monorepo 让packages/db生成的类型可以被apps/web和apps/api同时引用,不需要发布 npm 包。
依赖方面,核心是这几个:
next:React 全栈框架,提供 API Routes 和 SSR@trpc/server和@trpc/client:tRPC 的服务端和客户端@prisma/client和prisma:数据库 ORMzod:运行时类型校验,tRPC 的输入验证依赖它typescript:不用多说
版本选择上,我建议 Next.js 用 14 或 15,tRPC 用 11.x,Prisma 用 5.x。这些版本之间的兼容性比较稳定,社区资料也多。不要追最新的大版本,尤其是 tRPC 的 beta 版,API 变动频繁,踩坑成本高。
3.2 Prisma Schema 设计与数据库连接
Prisma 的 schema 文件是整个类型链路的起点。设计时要注意几个点:
第一,字段命名要统一。我见过有的项目数据库用 snake_case,Prisma 用 camelCase,结果每次查询都要手动映射。Prisma 支持@map和@@map来映射数据库列名,建议在 schema 里统一用 camelCase,然后用@map映射到数据库的 snake_case。这样 TypeScript 里拿到的字段名是 camelCase,符合前端习惯。
第二,关系字段要显式声明。比如User和Post是一对多,要在两边都写关系字段:
model User { id String @id @default(cuid()) posts Post[] } model Post { id String @id @default(cuid()) authorId String author User @relation(fields: [authorId], references: [id]) }这样 Prisma Client 生成的类型里,User会有posts: Post[],Post会有author: User。前端可以直接用这些关系字段,不需要手动 join。
第三,连接配置用环境变量。DATABASE_URL放在.env里,不要硬编码。Prisma 的datasource块里写url = env("DATABASE_URL")。本地开发用 SQLite 或 Docker 里的 PostgreSQL,生产环境换成正式的连接串。
生成 Prisma Client 的命令是npx prisma generate。每次改完 schema 都要跑一次,否则 TypeScript 类型不会更新。我习惯在package.json里加一个postinstall脚本,自动跑 generate,避免忘记。
3.3 tRPC 服务端与客户端配置
tRPC 的配置分服务端和客户端两部分。服务端先初始化:
// packages/api/src/trpc.ts import { initTRPC } from '@trpc/server'; import { ZodError } from 'zod'; const t = initTRPC.create({ errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const router = t.router; export const publicProcedure = t.procedure;这里用errorFormatter把 Zod 的校验错误格式化,前端可以拿到具体的字段错误信息。然后定义 router:
// packages/api/src/router/user.ts import { z } from 'zod'; import { publicProcedure, router } from '../trpc'; import { prisma } from '@my-t3-app/db'; export const userRouter = router({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { return prisma.user.findUnique({ where: { id: input.id }, }); }), create: publicProcedure .input(z.object({ name: z.string().min(1), email: z.string().email(), })) .mutation(async ({ input }) => { return prisma.user.create({ data: input, }); }), });注意input的校验用 Zod,这是 tRPC 的推荐做法。Zod schema 既做运行时校验,又做 TypeScript 类型推导。z.object({ id: z.string() })会自动推导出{ id: string }类型,前端调用时如果传{ id: 123 },TypeScript 会报错。
客户端配置在 Next.js 里通常用@trpc/next或@trpc/react-query。我推荐用 React Query 版本,因为缓存和状态管理更灵活:
// apps/web/src/utils/trpc.ts import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '@my-t3-app/api'; export const trpc = createTRPCReact<AppRouter>();然后在_app.tsx里包一层 Provider,配置 httpBatchLink 指向/api/trpc。这样前端组件里就可以直接trpc.user.getById.useQuery({ id: 'xxx' }),TypeScript 会自动提示id是 string 类型,返回值是User | null。
4. 核心环节实现:类型安全如何贯穿前后端
4.1 从数据库查询到前端渲染的完整链路
我们用一个具体场景来演示:展示用户列表,并支持点击查看详情。
后端在userRouter里加一个listprocedure:
list: publicProcedure .input(z.object({ limit: z.number().min(1).max(100).default(20), cursor: z.string().nullish(), })) .query(async ({ input }) => { const { limit, cursor } = input; const items = await prisma.user.findMany({ take: limit + 1, cursor: cursor ? { id: cursor } : undefined, orderBy: { createdAt: 'desc' }, }); let nextCursor: string | undefined = undefined; if (items.length > limit) { const nextItem = items.pop(); nextCursor = nextItem!.id; } return { items, nextCursor }; }),这个 procedure 的返回类型是{ items: User[], nextCursor: string | undefined }。前端调用时:
const { data, isLoading } = trpc.user.list.useQuery({ limit: 10 });data的类型自动推导为{ items: User[], nextCursor: string | undefined } | undefined。你在 JSX 里写data.items.map(user => ...),TypeScript 知道user是User类型,会提示user.name、user.email等字段。如果 Prisma Schema 里把email改成emailAddress,这里立刻报错,你不得不改。
这就是类型安全的威力:编译器帮你找 bug,而不是等用户发现。
4.2 输入校验与错误处理的最佳实践
tRPC 的输入校验用 Zod,但很多人只在校验层写z.string(),忽略了错误处理。实际项目中,我建议把 Zod schema 抽出来单独维护,比如:
// packages/shared/src/schemas/user.ts export const createUserSchema = z.object({ name: z.string().min(1, '名字不能为空').max(50, '名字太长'), email: z.string().email('邮箱格式不对'), age: z.number().int().positive().optional(), });然后在 procedure 里引用:
create: publicProcedure .input(createUserSchema) .mutation(async ({ input }) => { // input 类型自动推导 }),这样做的好处是:前端表单校验可以直接复用同一个 schema。比如用react-hook-form的 zodResolver,把createUserSchema传进去,前端校验规则和后端完全一致。用户填错邮箱,前端立刻提示“邮箱格式不对”,不需要等请求发到后端再返回错误。
错误处理方面,tRPC 的TRPCError可以自定义错误码和消息:
if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在', }); }前端可以用error.data?.code判断错误类型,做不同的 UI 展示。比如NOT_FOUND显示“用户不存在”,UNAUTHORIZED跳转登录页。
4.3 中间件与权限控制的实现
t3code 项目里,权限控制通常用 tRPC 的 middleware 实现。比如要保护某些 procedure 只能登录用户访问:
const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return next({ ctx: { ...ctx, session: ctx.session, }, }); }); export const protectedProcedure = t.procedure.use(isAuthed);然后userRouter里需要登录的 procedure 用protectedProcedure替代publicProcedure。这样ctx.session.user在 procedure 里就是非空的,TypeScript 知道它一定存在,不需要写ctx.session?.user?.id这种可选链。
这种“类型收窄”是 t3code 体系里很优雅的一点:权限检查不仅运行时生效,类型层面也生效。你不可能在受保护的 procedure 里忘记检查登录状态,因为类型系统不允许。
5. 常见问题与排查技巧实录
5.1 类型推导失败:为什么前端拿不到后端类型
这是新手最常遇到的问题:前端调用trpc.user.getById.useQuery时,TypeScript 提示AppRouter类型不存在,或者data是any。
原因通常有三个:
第一,后端 router 没有导出类型。在packages/api/src/index.ts里要导出AppRouter:
export type AppRouter = typeof appRouter;前端createTRPCReact<AppRouter>()引用的就是这个类型。如果没导出,或者导出的是appRouter实例而不是类型,推导就会失败。
第二,monorepo 的 tsconfig 路径映射不对。前端项目要能解析到@my-t3-app/api的类型声明。检查tsconfig.json里的paths配置,确保指向正确的源码目录,而不是编译后的dist。
第三,TypeScript 版本不一致。前后端用的 TypeScript 版本如果差太多,类型推导可能出问题。建议在根目录的package.json里锁定 TypeScript 版本,所有子包共用。
排查方法:在packages/api/src/index.ts里写一个测试类型type Test = AppRouter;,鼠标悬停看 TypeScript 能不能展开。如果展开是any,说明 router 定义有问题;如果能展开出具体的 procedure,说明类型本身没问题,是前端引用路径的问题。
5.2 Prisma Client 生成失败与数据库迁移冲突
Prisma 的常见坑有两个:一是prisma generate报错,二是prisma migrate dev冲突。
generate报错通常是 schema 语法问题。Prisma 的 schema 语言比较严格,少一个@或?都会报错。建议用npx prisma format先格式化,它会自动修正缩进和部分语法问题。如果还报错,看错误信息里的行号,逐行检查。
migrate dev冲突通常发生在团队协作时:你改了一个字段,同事也改了同一个字段,迁移文件冲突。解决方法是先npx prisma migrate dev --create-only生成迁移文件但不执行,手动检查 SQL 是否有冲突,合并后再npx prisma migrate dev执行。如果冲突严重,可以考虑npx prisma migrate reset重置数据库,但生产环境千万别这么干。
另一个坑是:Prisma Client 的类型更新滞后。你改了 schema,跑了migrate dev,但忘了generate,TypeScript 类型还是旧的。建议在package.json里加:
"scripts": { "db:migrate": "prisma migrate dev && prisma generate" }每次迁移后自动生成,避免忘记。
5.3 前后端类型不一致的排查流程
即使有 t3code 体系,偶尔也会遇到类型不一致的情况。比如前端拿到的user.avatarUrl是string,但实际运行时是null,导致图片渲染报错。
排查流程如下:
第一步,检查 Prisma Schema 里avatarUrl是否声明为可选(String?)。如果是必填(String),但数据库里有 null 值,Prisma 查询时会报错。这种情况要改 schema 为可选,然后迁移。
第二步,检查 tRPC procedure 的返回类型。如果 procedure 里手动构造了返回对象,比如return { ...user, avatarUrl: user.avatarUrl || '' },那类型会变成string,但数据库里可能是 null。这种“手动修正”要谨慎,最好保持类型和数据库一致。
第三步,检查前端是否用了as强制类型转换。as会绕过类型检查,是类型不一致的常见来源。如果发现as,尽量用类型守卫替代:
if (typeof user.avatarUrl === 'string') { // 这里 user.avatarUrl 是 string }5.4 性能优化:tRPC 批量请求与缓存策略
tRPC 默认每个请求单独发送,如果页面同时调用多个 procedure,会有多个 HTTP 请求。优化方法是启用httpBatchLink:
httpBatchLink({ url: '/api/trpc', maxURLLength: 2083, })这样多个 query 会合并成一个请求,减少网络开销。但要注意maxURLLength,如果请求参数太大,URL 会超长,需要调大这个值或改用httpBatchStreamLink。
缓存方面,React Query 自带缓存机制。useQuery的staleTime控制数据多久后重新请求。对于不常变的数据,比如用户信息,可以设staleTime: 5 * 60 * 1000(5 分钟)。对于实时性要求高的,比如消息列表,设staleTime: 0,每次聚焦页面都刷新。
还有一个技巧:用trpc.user.getById.useQuery({ id }, { enabled: !!id })控制查询时机。如果id是空字符串,不发送请求。这在详情页很常用,避免用空 id 请求导致 404。
6. 我的实操心得与避坑清单
6.1 项目结构设计的三个原则
做了几个 t3code 项目后,我总结了三条结构设计原则:
原则一:类型定义靠近数据源。Prisma Schema 在packages/db,tRPC router 在packages/api,前端组件在apps/web。类型从 db 流向 api,再流向 web,方向单一,不反向依赖。如果前端需要定义一个只在 UI 用的类型,放在apps/web/src/types里,不要放到共享包。
原则二:共享代码最小化。packages/shared只放真正跨端复用的东西,比如 Zod schema、工具函数、常量。不要把 React 组件放进去,否则后端会引入不必要的依赖。
原则三:环境变量分端管理。数据库连接串只在packages/db用,前端不要读。API 密钥只在packages/api用。Next.js 的NEXT_PUBLIC_前缀变量才会暴露给前端,其他变量前端读不到,这是安全边界。
6.2 团队协作中的类型同步经验
团队用 t3code 体系时,最大的挑战不是技术,而是习惯。以前前端习惯“先写 mock,等后端接口”,现在必须“等后端定义好 procedure 再写前端”。这个顺序调整需要磨合。
我的经验是:后端先写 procedure 签名和 Zod schema,不写实现。比如:
getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { throw new Error('Not implemented'); }),前端看到这个签名,就知道参数和返回类型,可以开始写 UI 和调用逻辑。后端再慢慢填实现。这样前后端可以并行开发,又不破坏类型契约。
另外,每次改 procedure 签名,要在群里同步一声。虽然 TypeScript 会报错,但提前沟通能减少返工。
6.3 从 JavaScript 迁移到 t3code 的渐进路径
如果团队现有项目是 JavaScript,想迁移到 t3code,不要一次性重写。我建议分三步:
第一步,新功能用 TypeScript。在现有项目里加tsconfig.json,允许.ts和.js混用。新写的模块用 TypeScript,老代码不动。
第二步,引入 Prisma 管理数据库。把现有的 SQL 查询逐步替换成 Prisma Client。Prisma 可以和原生 SQL 共存,不需要一次性迁移所有表。
第三步,新接口用 tRPC。老接口保持 REST,新接口用 tRPC。前端逐步从fetch切换到trpc。等老接口没人用了,再删掉。
这个过程可能持续几个月,但风险可控。每步都有明确的收益,团队也能逐步适应 TypeScript 的开发方式。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
前端data类型为any | AppRouter 未导出或路径错误 | 检查packages/api/src/index.ts是否导出AppRouter | 导出export type AppRouter = typeof appRouter |
prisma generate报错 | schema 语法错误 | 运行npx prisma format | 根据错误行号修正 schema |
| 迁移冲突 | 多人修改同一字段 | 查看prisma/migrations目录 | 用--create-only手动合并 |
| 运行时类型不匹配 | 用了as强制转换 | 搜索代码里的as | 用类型守卫替代 |
| 请求过多 | 未启用 batch link | 检查 tRPC client 配置 | 启用httpBatchLink |
| 缓存不更新 | staleTime设置过长 | 检查useQuery参数 | 调整staleTime或手动invalidate |
这张表是我在实际项目中遇到问题后整理的,基本覆盖了 80% 的常见故障。遇到新问题,先查表,再查文档,最后去社区搜。
7. 这套东西的边界在哪里
t3code 体系很强大,但不是所有项目都适合。我个人的判断标准是:如果项目是全栈 TypeScript,团队人数在 2 到 20 人之间,迭代速度快,那 t3code 非常合适。它能把接口联调的时间省下来,让开发者专注业务逻辑。
但如果项目是前后端分离的大团队,后端用 Java 或 Go,前端用 TypeScript,那 t3code 的核心优势就没了。这时候用 OpenAPI 生成类型更实际。另外,如果项目对性能要求极高,比如高频交易系统,tRPC 的 HTTP 开销可能成为瓶颈,需要评估。
还有一个容易被忽略的点:t3code 的学习曲线。TypeScript 的高级类型、泛型、条件类型,在 tRPC 和 Prisma 的类型推导里大量使用。如果团队里有人不熟悉这些,调试类型错误会很痛苦。我的建议是,先让一两个人深入掌握,再逐步推广。
最后分享一个小技巧:在tsconfig.json里开启strict: true和noUncheckedIndexedAccess: true。前者让 TypeScript 更严格,后者让数组索引访问返回T | undefined,避免越界错误。这两个选项一开始会报很多错,但改完之后,代码的健壮性会提升一个档次。我试过在一个中型项目里开启,花了半天修类型错误,但之后线上 bug 少了将近三成。这个投入产出比,很值。