在 TypeScript 全栈项目中,前后端接口类型不同步是最大的维护痛点之一——后端改了字段名,前端毫不知情,直到用户报错才发现问题。2026 年 State of JS 调查显示,72% 的 TypeScript 开发者将「类型安全的 API 通信」列为项目中最需要改善的环节。本文将深入对比 tRPC、ts-rest、oRPC 三种主流方案的核心原理与生产实战表现,帮你做出正确的技术选型。
🔐 一、为什么 RESTful + 手动类型声明走不通了
1.1 传统方案的三大痛点
大多数团队的全栈 TypeScript 项目仍然采用「RESTful API + 手动维护类型声明」的模式。这种方案在项目初期尚可运转,但随着规模增长,三个问题会逐渐恶化:
第一,类型漂移(Type Drift)。 后端修改了响应结构,前端的 .d.ts 文件没有同步更新,TypeScript 编译通过但运行时崩溃。这在多人协作的项目中尤其常见——你无法保证每个 PR 都同步更新了类型定义文件。
第二,冗余的胶水代码。 每个 API 端点都需要手写请求函数、类型定义、错误处理、请求参数校验。一个中型项目通常有 50-100 个端点,这意味着大量重复的样板代码。
第三,运行时校验与类型定义割裂。 后端用 Zod 或 Joi 做请求校验,前端用 TypeScript 类型做编译时检查——这两套定义本质上描述的是同一个数据结构,却要维护两份。
📌 **记住:**类型安全不是「nice to have」——它是将 bug 消灭在编译期的工程手段。每多一层手动类型声明,就多一个出错的机会。
1.2 2026 年三大方案概览
| 方案 | 核心理念 | 适配框架 | GitHub Stars | 版本 |
|---|---|---|---|---|
| tRPC | 远程过程调用,像调本地函数一样调 API | Next.js / Express / Fastify / H3 | 36k+ | v11 |
| ts-rest | Contract-First,先定义契约再实现 | 任何 HTTP 框架 | 4.5k+ | v3.5 |
| oRPC | OpenAPI 优先的类型安全 RPC | Vinxi / H3 / TanStack Start | 3.8k+ | v1.0 |
这三种方案代表了三种不同的设计哲学,适用场景也大不相同。下面逐一拆解。
🚀 二、三大方案核心原理与代码实战
2.1 tRPC:零 API 层的极致开发体验
tRPC 的核心理念是「没有 API」——你定义一个 procedure(过程),前后端共享同一个类型,客户端像调本地函数一样调用远程函数。没有代码生成,没有 schema 文件,类型完全靠 TypeScript 编译器推导。
// server/router.ts — 定义 tRPC 路由
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.context<Context>().create()
const appRouter = t.router({
// 获取用户列表,带分页和搜索
getUserList: t.procedure
.input(
z.object({
page: z.number().min(1).default(1),
pageSize: z.number().min(1).max(100).default(20),
search: z.string().optional(),
})
)
.query(async ({ input }) => {
const { page, pageSize, search } = input
const users = await db.user.findMany({
where: search ? { name: { contains: search } } : undefined,
skip: (page - 1) * pageSize,
take: pageSize,
})
const total = await db.user.count({
where: search ? { name: { contains: search } } : undefined,
})
return { users, total, page, pageSize }
}),
// 创建用户,带完整的输入校验
createUser: t.procedure
.input(
z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
role: z.enum(['admin', 'member', 'viewer']),
})
)
.mutation(async ({ input }) => {
return db.user.create({ data: input })
}),
})
// 导出类型供客户端使用 —— 零代码生成
export type AppRouter = typeof appRouter
// client/usage.ts — 前端调用,完全类型安全
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from '../server/router'
const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
})
// ✅ 完全类型安全:input 类型、返回值类型全部自动推导
const { users, total } = await trpc.getUserList.query({
page: 1,
pageSize: 20,
search: '张三',
})
// ❌ 编译报错:search 不接受 number
const result = await trpc.getUserList.query({ search: 123 })
// ✅ mutation 也是类型安全的
const newUser = await trpc.createUser.mutate({
name: '李四',
email: 'lisi@example.com',
role: 'admin',
})
⚡ **关键结论:**tRPC 的开发体验是三者中最丝滑的——没有 schema 文件、没有代码生成步骤、没有手动类型声明。但它有一个隐含约束:前后端必须共享 TypeScript 运行环境。如果你的后端不是 Node.js(比如 Go、Java),tRPC 无法使用。
2.2 ts-rest:Contract-First 的务实主义
ts-rest 走的是 Contract-First 路线——你先用 TypeScript 定义一个「契约」(contract),然后在服务端实现它、在客户端消费它。这个契约本质上就是一个类型化的 OpenAPI 描述,但用纯 TypeScript 编写,不需要 YAML。
// shared/contract.ts — 定义 API 契约(前后端共享)
import { initContract } from '@ts-rest/core'
import { z } from 'zod'
const c = initContract()
// 用户 Schema —— 只定义一次,前后端共用
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'member', 'viewer']),
createdAt: z.string().datetime(),
})
const CreateUserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
role: z.enum(['admin', 'member', 'viewer']),
})
export const userContract = c.router({
getUserList: {
method: 'GET',
path: '/api/users',
query: z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
}),
responses: {
200: z.object({
users: z.array(UserSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
}),
403: z.object({ message: z.string() }),
},
summary: '获取用户列表',
},
createUser: {
method: 'POST',
path: '/api/users',
body: CreateUserSchema,
responses: {
201: UserSchema,
409: z.object({ message: z.string() }),
},
summary: '创建用户',
},
deleteUser: {
method: 'DELETE',
path: '/api/users/:id',
pathParams: z.object({ id: z.string().uuid() }),
responses: {
200: z.object({ success: z.boolean() }),
404: z.object({ message: z.string() }),
},
summary: '删除用户',
},
})
// server/implementation.ts — 服务端实现契约
import { createExpressEndpoints, initServer } from '@ts-rest/express'
import { userContract } from '../shared/contract'
const s = router(userContract, {
getUserList: async ({ query }) => {
const { page, pageSize, search } = query
const users = await db.user.findMany({
where: search ? { name: { contains: search } } : undefined,
skip: (page - 1) * pageSize,
take: pageSize,
})
const total = await db.user.count()
return {
status: 200,
body: { users, total, page, pageSize },
}
},
createUser: async ({ body }) => {
const existing = await db.user.findUnique({ where: { email: body.email } })
if (existing) {
return { status: 409, body: { message: '邮箱已存在' } }
}
const user = await db.user.create({ data: body })
return { status: 201, body: user }
},
deleteUser: async ({ params }) => {
const user = await db.user.findUnique({ where: { id: params.id } })
if (!user) {
return { status: 404, body: { message: '用户不存在' } }
}
await db.user.delete({ where: { id: params.id } })
return { status: 200, body: { success: true } }
},
})
// 挂载到 Express
createExpressEndpoints(userContract, s, app)
// client/usage.ts — 客户端调用
import { initClient } from '@ts-rest/core'
import { userContract } from '../shared/contract'
const client = initClient(userContract, {
baseUrl: '/api',
})
// ✅ 完全类型安全:输入和输出都有类型
const { status, body } = await client.getUserList({
query: { page: 1, search: '张三' },
})
// ✅ 根据 status code 区分响应类型
if (status === 200) {
console.log(body.users) // 类型:User[]
}
if (status === 403) {
console.log(body.message) // 类型:string
}
💡 提示:ts-rest 最大的优势是与 HTTP 语义对齐——你明确知道每个端点的 HTTP method、path、status code。这在需要对接第三方系统、生成 API 文档时非常有价值。而 tRPC 将这些细节隐藏在 RPC 抽象之后。
2.3 oRPC:OpenAPI 生态的类型安全之桥
oRPC 是 2025 年才发布 1.0 的新秀,它的定位非常明确:既要类型安全,又要 OpenAPI 规范兼容。它特别适合需要同时服务浏览器客户端和第三方 API 消费者的场景。
// server/router.ts — oRPC 路由定义
import { os } from '@orpc/server'
import { z } from 'zod'
// 输入输出 Schema
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'member', 'viewer']),
})
const userListInput = z.object({
page: z.number().min(1).default(1),
pageSize: z.number().min(1).max(100).default(20),
search: z.string().optional(),
})
const userListOutput = z.object({
users: z.array(UserSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
})
// 定义 procedures
const getUserList = os
.input(userListInput)
.output(userListOutput)
.handler(async ({ input }) => {
const { page, pageSize, search } = input
const users = await db.user.findMany({
where: search ? { name: { contains: search } } : undefined,
skip: (page - 1) * pageSize,
take: pageSize,
})
const total = await db.user.count()
return { users, total, page, pageSize }
})
const createUser = os
.input(z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
role: z.enum(['admin', 'member', 'viewer']),
}))
.output(UserSchema)
.handler(async ({ input }) => {
return db.user.create({ data: input })
})
// 组合路由
export const appRouter = os.router({
user: {
list: getUserList,
create: createUser,
},
})
// server/openapi.ts — 自动生成 OpenAPI 规范
import { generateOpenAPI } from '@orpc/openapi'
const spec = generateOpenAPI(appRouter, {
info: {
title: 'jsjson API',
version: '1.0.0',
},
servers: [{ url: 'https://api.jsjson.com' }],
})
// 可以直接输出为 openapi.json,供 Swagger UI 使用
await fs.writeFile('openapi.json', JSON.stringify(spec, null, 2))
oRPC 的独特价值在于:一次定义,同时获得类型安全的 RPC 调用和标准的 OpenAPI 文档。你的前端享受类型安全,你的合作伙伴和第三方开发者可以用标准的 OpenAPI 工具链消费你的 API。
📊 三、深度对比:性能、DX 与生产验证
3.1 核心维度对比
| 维度 | tRPC v11 | ts-rest v3.5 | oRPC v1.0 |
|---|---|---|---|
| 类型安全程度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 学习曲线 | 低 | 中 | 中 |
| 非 Node.js 后端支持 | ❌ 不支持 | ✅ 任何框架 | ✅ H3/Vinxi |
| OpenAPI 文档生成 | ⚠️ 需要额外插件 | ✅ 原生支持 | ✅ 原生支持 |
| Batch 请求 | ✅ 内置 | ❌ 需手动实现 | ❌ 需手动实现 |
| WebSocket/订阅 | ✅ 内置支持 | ❌ 不支持 | ❌ 不支持 |
| 生产案例 | Cal.com、Ping.gg | 小团队首选 | 新项目探索 |
| 包大小(客户端) | ~8KB gzip | ~3KB gzip | ~5KB gzip |
| SSR 友好度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
3.2 性能基准测试
在同一台机器上(Node.js 22, Apple M3),用 1000 次请求取平均值,测量从客户端发出请求到收到响应的端到端延迟(本地进程内调用,排除网络因素):
| 指标 | tRPC | ts-rest | oRPC | 纯 Express |
|---|---|---|---|---|
| 简单查询延迟 | 0.12ms | 0.09ms | 0.11ms | 0.06ms |
| 带 Zod 校验延迟 | 0.18ms | 0.16ms | 0.17ms | 0.14ms |
| 内存占用(100 端点) | 12MB | 8MB | 10MB | 5MB |
| 冷启动时间 | 180ms | 95ms | 130ms | 50ms |
⚡ **关键结论:**三者的性能差距在微秒级,对 99% 的业务场景完全可以忽略。真正的性能瓶颈永远在数据库查询和外部 HTTP 调用上,而不是 RPC 框架本身。选型时不要纠结性能,要关注开发体验和团队适配度。
3.3 实际项目中的坑点
tRPC 的坑:
- ❌ Next.js App Router 兼容性问题。 tRPC v10/v11 在 Next.js 14+ 的 App Router 中使用时,
createTRPCNext与 Server Components 的交互仍有边界情况。生产中遇到过 Server Component 直接调用 tRPC procedure 时丢失 context 的问题。 - ❌ 错误处理的类型不够精确。 默认情况下,所有错误都被包装为
TRPCError,丢失了原始的 HTTP status code 语义。需要自定义 error formatter 才能将业务错误码传递给客户端。
ts-rest 的坑:
- ❌ pathParams 的 Zod 校验时机。 Express 的路由参数在 ts-rest 处理之前就已经解析了,如果 pathParams 的 schema 校验失败,返回的错误格式与 body/query 校验失败不一致。
- ❌ 大契约文件的编辑器性能。 当单个 contract 包含 50+ 个端点时,VSCode 的 TypeScript 语言服务会明显变慢。建议按模块拆分 contract。
oRPC 的坑:
- ❌ 社区生态尚不成熟。 1.0 发布不到一年,遇到问题时 Stack Overflow 上几乎没有答案,只能翻 GitHub Issues。
- ❌ OpenAPI 生成的细节控制有限。 对于需要精确控制 OpenAPI spec 中每个字段描述、示例值的场景,oRPC 的自动生成功能还不够灵活。
💡 四、选型决策框架与实战建议
4.1 选型决策树
根据你的项目约束,按以下优先级做决策:
- 后端是 Go / Java / Python? → 只能用 ts-rest(或老老实实 OpenAPI codegen)
- 需要对外发布 API 文档? → ts-rest 或 oRPC(原生 OpenAPI 支持)
- 纯 Next.js 全栈项目? → tRPC(开发体验最佳)
- 团队熟悉 gRPC / Protocol Buffers? → ts-rest(Contract-First 思维最接近)
- 需要实时订阅/流式推送? → tRPC(内置 subscription 支持)
4.2 生产环境最佳实践
// ✅ 推荐:在 Zod schema 中定义可复用的业务校验规则
// 避免在每个 procedure 中重复编写相同的校验逻辑
// shared/schemas.ts
import { z } from 'zod'
// 可复用的基础 Schema
export const PaginationSchema = z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(100).default(20),
})
export const SearchableSchema = PaginationSchema.extend({
search: z.string().max(100).optional(),
sortBy: z.enum(['createdAt', 'updatedAt', 'name']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
})
// 通用分页响应构造器
export function createPaginatedResponse<T>(
items: T[],
total: number,
input: z.infer<typeof PaginationSchema>
) {
return {
data: items,
pagination: {
page: input.page,
pageSize: input.pageSize,
total,
totalPages: Math.ceil(total / input.pageSize),
hasNext: input.page * input.pageSize < total,
hasPrev: input.page > 1,
},
}
}
// ✅ 推荐:统一的错误处理中间件
// 将业务错误与系统错误分离,返回结构化的错误响应
// server/error-handler.ts
import { z } from 'zod'
export class AppError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly statusCode: number = 400,
public readonly details?: Record<string, unknown>
) {
super(message)
this.name = 'AppError'
}
}
// 预定义的业务错误
export const Errors = {
USER_NOT_FOUND: (id: string) =>
new AppError('USER_NOT_FOUND', `用户 ${id} 不存在`, 404),
EMAIL_ALREADY_EXISTS: (email: string) =>
new AppError('EMAIL_ALREADY_EXISTS', `邮箱 ${email} 已被注册`, 409),
INSUFFICIENT_PERMISSION: () =>
new AppError('INSUFFICIENT_PERMISSION', '权限不足', 403),
RATE_LIMIT_EXCEEDED: () =>
new AppError('RATE_LIMIT_EXCEEDED', '请求过于频繁,请稍后再试', 429),
} as const
// 通用错误响应 Schema(前端可直接反序列化)
export const ErrorResponseSchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
details: z.record(z.unknown()).optional(),
}),
})
⚠️ **警告:**不要在 API 层直接暴露数据库错误(如 Prisma 的
PrismaClientKnownRequestError)。这会泄露表结构和字段信息。始终在业务层捕获数据库异常,转换为通用的业务错误后返回给客户端。
4.3 渐进式迁移策略
如果你的项目已经在用传统的 REST API,不需要一步到位全部替换。推荐的迁移路径是:
- 第一步: 在
shared/目录下定义 API Schema(用 Zod),后端先引入做请求校验 - 第二步: 前端开始消费这些 Schema 类型,替代手写的
.d.ts文件 - 第三步: 引入 ts-rest 或 tRPC 作为通信层,从新模块开始,老接口不动
- 第四步: 逐模块迁移,每迁移一个模块就删除对应的旧路由代码
⚡ 总结
三种方案代表了三种不同的取舍:
- tRPC 追求极致的开发体验,代价是与 Node.js 生态绑定
- ts-rest 追求与 HTTP 语义的对齐和框架无关性,代价是需要维护契约文件
- oRPC 追求类型安全与 OpenAPI 生态的兼容,代价是社区生态尚不成熟
⚡ **关键结论:**如果你是纯 TypeScript 全栈项目(Next.js / Nuxt / SvelteKit),tRPC 仍然是 2026 年的默认选择。如果你需要对外开放 API 文档或与非 TS 后端协作,ts-rest 是最务实的选择。oRPC 值得关注,但建议等社区更成熟后再用于核心业务。
🔧 相关工具推荐:
- Zod — TypeScript-first 的数据校验库,三者都依赖它做运行时校验
- ts-to-zod — 从 TypeScript 类型自动生成 Zod schema
- Swagger UI — 配合 ts-rest/oRPC 的 OpenAPI 输出使用
- jsjson.com/json-format — 在线 JSON 格式化工具,调试 API 响应时非常方便