前后端联调大概是整个开发流程里最消耗耐心的事情之一。前端说后端返回的字段和文档不一致,后端说前端传的参数类型不对。这种问题在项目早期还能靠沟通解决,一旦接口多起来,就会变成一场持续的拉锯战。于是有人开始思考:既然前后端都用 TypeScript,为什么不把类型直接共享给两端,让编译器帮我们盯着?

这就是 tRPC 出现的背景。tRPC 不是一个库,而是一套基于 TypeScript 的类型安全 API 调用方案。它试图把 REST 时代的“接口文档 + 手工调用”模式,变成“类型即契约”的全栈体验。
这篇文章不会急着给出“REST 已死”的结论,而是想聊聊从 REST 到 tRPC 这个转变,到底解决了什么问题,又引入了哪些新的权衡。
为什么 REST 在类型安全上天然吃亏
REST 本身是资源导向的架构风格,它并不关心数据类型。接口的请求参数和响应体都是通过 JSON 传输的,而 JSON 是一种弱类型格式。前端拿到数据后,需要自己定义 interface,或者依赖手写的类型声明。后端改了一个字段名,前端不一定能立刻发现,往往要等联调甚至线上报错才能察觉。
真正麻烦的地方在于,接口文档和代码是分离的。就算用了 Swagger 自动生成,文档里的类型和实际代码也可能不同步。而且,随着微服务拆分,前端可能要维护多个服务的接口类型,维护成本成倍增加。
想象一个中大型项目,前端团队十几个人,后端也有十几人。接口数量几百个,每次接口变更都要在群里通知,然后前端手动更新类型定义。如果你经历过这种日子,应该能理解“类型安全”这三个字的重量。
tRPC 做了什么:类型即契约
tRPC 的核心思路很简单:把后端路由定义为一个类型化的对象,前端通过这个类型安全的客户端直接调用。不需要代码生成,不需要额外的 schema 文件,一切都源自 TypeScript 的类型推断能力。
// server.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
return user;
}),
});
export type AppRouter = typeof appRouter;
// client.ts
import { createTRPCClient } from '@trpc/client';
import type { AppRouter } from './server';
const client = createTRPCClient<AppRouter>({ url: 'http://localhost:3000/trpc' });
const user = await client.getUser.query({ id: '1' });
// user 类型自动推断为 { id: string; name: string; ... } | null
这里有几个关键点:服务端用 zod 定义输入校验,输出类型由 query 函数的返回值推断。客户端通过 AppRouter 类型获得完整的类型信息。如果后端返回的结构变了,前端调用处会直接编译报错。
这背后没有魔法,本质上是 TypeScript 的类型系统和 HTTP 封装在配合工作。tRPC 只是把这两个原本分离的东西粘在了一起。
tRPC 的优势不只是类型安全
除了编译期的类型检查,tRPC 还有几个工程上的优势:
- 不用维护独立的接口文档:类型本身成为接口契约,代码即文档。
- 前后端并行开发更容易:只要定义好 router 的类型,前端就可以基于类型 mock 或直接开发。
- 减少运行时错误:很多字段名、嵌套结构的问题在编译期就被发现,而不是等线上报警。
但也要清醒地看到,tRPC 并不是万能的。它最适用的场景是前后端都使用 TypeScript 的全栈项目,比如 Next.js、Remix、Express 等。如果客户端是第三方应用,或者需要开放 API 给其他语言,REST 或 GraphQL 可能更合适。
REST 与 tRPC 的关键对比
| 维度 | REST | tRPC |
|---|---|---|
| 类型安全 | 依赖 Swagger 或手写类型 | 原生类型推断 |
| 接口文档 | OpenAPI 等外部工具 | 类型即文档 |
| 学习成本 | 低,通用标准 | 中等,需理解 TS 类型系统 |
| 调试工具 | Postman、浏览器 DevTools | 需要 tRPC 插件或自定义日志 |
| HTTP 缓存 | 天然支持 | 默认不支持,需额外设计 |
| 版本控制 | 可以独立版本化 | 类型变更影响所有客户端 |
| 适用场景 | 公开 API、多语言客户端 | TypeScript 全栈内部服务 |
使用 tRPC 前需要认清的几个误区
误区一:以为 tRPC 不需要写任何校验。实际上,tRPC 依赖输入解析(比如 zod)来保证运行时安全。如果不用 zod,类型只是编译期的幻觉,运行时可能收到脏数据。
误区二:认为 tRPC 是 GraphQL 的替代品。虽然两者都追求高效的前后端协作,但 GraphQL 强调客户端按需查询,tRPC 则是按函数调用。它们解决的问题有重叠,但设计哲学不同。
误区三:把 tRPC 当作 REST 的完全替代品,强行改造所有接口。tRPC 不适合公开 API,也不适合需要复杂 HTTP 缓存、代理和网关策略的场景。内部服务之间用 tRPC 很爽,但对外还是要用标准协议。
提醒一下:tRPC 的类型安全是“编译期”的,不是“运行期”的。输入校验、错误处理、日志这些工程基本功,一个都不能少。
在现有项目里落地 tRPC 的建议
如果你决定在项目中尝试 tRPC,建议从新模块开始,而不是重构现有 REST 接口。可以把 tRPC 路由挂在现有服务上,比如 /trpc 路径,逐步迁移。同时注意以下几点:
- 类型定义放在共享包中,或者直接导入服务端类型(但要避免客户端依赖服务端代码,可以通过类型导出解决)。
- 使用 zod 做输入校验,尽量覆盖边界情况。
- 日志和错误处理要完善,因为 tRPC 的错误信息默认比较简略。
- 考虑查询的缓存策略,tRPC 默认没有 React Query 那样的缓存机制,需要自己接入。
一个小型全栈团队,用 Next.js 写前端,后端用 Node.js,两个人负责所有开发。他们引入 tRPC 后,发现接口联调的时间几乎降为零,因为类型不匹配在编译期就暴露了。但当他们想给第三方公司开放 API 时,发现 tRPC 的客户端是 TypeScript 专用的,对方只能手写 HTTP 调用,这时他们才意识到 tRPC 的边界。
另一个场景:大型团队,几百个接口,前期为了快速交付,没有引入类型安全,后来维护成本爆炸,开始尝试用 tRPC 重写核心链路。这个过程中发现,老接口的语义混乱、嵌套太深,迁移成本不低。所以 tRPC 不是万能药,它解决的是“新项目”或“可控项目”里的类型断裂问题,而不是历史包袱。
总结
从 REST 到 tRPC,本质上是把编程语言层面的类型系统延伸到网络边界。它让前后端协作从“约定”变成了“强制”。但技术选型永远没有银弹,tRPC 用起来舒服,前提是你能接受它的限制:纯 TypeScript 生态、内部服务优先、需要更主动的缓存设计。如果你的团队恰好是 TypeScript 全栈,并且被接口类型问题折磨过,那么 tRPC 值得一试。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/475/