全栈 TypeScript 类型安全 API 实战:tRPC、ts-rest、oRPC 深度对比与选型指南

深入对比 2026 年主流全栈 TypeScript 类型安全方案——tRPC、ts-rest、oRPC 的核心原理、性能表现与适用场景,附完整可运行代码和选型决策框架。

前端开发 2026-06-11 15 分钟

在 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.comPing.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 选型决策树

根据你的项目约束,按以下优先级做决策:

  1. 后端是 Go / Java / Python? → 只能用 ts-rest(或老老实实 OpenAPI codegen)
  2. 需要对外发布 API 文档? → ts-rest 或 oRPC(原生 OpenAPI 支持)
  3. 纯 Next.js 全栈项目? → tRPC(开发体验最佳)
  4. 团队熟悉 gRPC / Protocol Buffers? → ts-rest(Contract-First 思维最接近)
  5. 需要实时订阅/流式推送? → 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,不需要一步到位全部替换。推荐的迁移路径是:

  1. 第一步:shared/ 目录下定义 API Schema(用 Zod),后端先引入做请求校验
  2. 第二步: 前端开始消费这些 Schema 类型,替代手写的 .d.ts 文件
  3. 第三步: 引入 ts-rest 或 tRPC 作为通信层,从新模块开始,老接口不动
  4. 第四步: 逐模块迁移,每迁移一个模块就删除对应的旧路由代码

⚡ 总结

三种方案代表了三种不同的取舍:

  • 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 响应时非常方便

📚 相关文章