tRPC 实战:用类型安全打通前后端 API

在 TypeScript 全栈项目里,前后端类型常常是两个”孤岛”:后端改了字段名,前端编译期毫无察觉,直到运行时白屏。tRPC 用类型安全 API的思路彻底解决这个问题——它不生成代码、不写 OpenAPI,而是让前端直接调用后端 router,参数与返回值的类型从服务端自动推断。本文用一个能跑通的示例,讲清 tRPC 的核心用法、与 Zod 的协作,以及在 Next.js 中的落地方式。

一、为什么需要 tRPC:前后端类型的鸿沟

传统 REST 方案里,前端要”猜”后端返回什么。要么手写一份 interface 手动同步,要么靠 OpenAPI 生成客户端——前者容易漂移,后者引入一整套工具链。更隐蔽的是,TypeScript 的运行时类型校验只在编译期生效,接口响应的真实结构在运行时仍是不可信的。

tRPC 的聪明之处在于:既然前后端都是 TypeScript,那就让类型成为契约本身。你只写一份 router 定义,前端拿到的调用对象就自带完整类型,服务端一改字段,前端调用处立刻红线报错。没有代码生成、没有中间描述文件,类型从服务端”流过”网络直接落到客户端。

二、5 分钟跑通一个 tRPC 服务端

核心只有三步:用 initTRPC 创建上下文、用 z 做入参校验、把多个子 router 合并成 appRouter。下面是一段最小可运行的服务端代码:

// server/trpc.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";

const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;

// server/routers/user.ts
const userRouter = router({
  list: publicProcedure.query(async () => {
    return [
      { id: 1, name: "Ada" },
      { id: 2, name: "Linus" },
    ];
  }),
  getById: publicProcedure
    .input(z.object({ id: z.number() }))
    .query(async ({ input }) => {
      return { id: input.id, name: `user-${input.id}` };
    }),
});

export const appRouter = router({
  user: userRouter,
});
// 关键:把类型导出给前端,零运行时成本
export type AppRouter = typeof appRouter;

注意最后一行 export type AppRouter——它没有任何运行时开销,只是把 router 的”形状”送给前端。这正是 tRPC 类型安全的来源。

三、前端直接调用:类型自动推断

前端用 @trpc/client 创建一个代理客户端,所有方法、参数、返回值都从 AppRouter 推断而来。服务端改了 getById 的入参,前端如果传错类型,IDE 会立刻标红:

// client.ts
import { createTRPCProxyClient } from "@trpc/client";
import type { AppRouter } from "../server/trpc";

const trpc = createTRPCProxyClient<AppRouter>({
  url: "http://localhost:3000/trpc",
});

// 返回值类型自动推断为 { id: number; name: string }[]
const users = await trpc.user.list.query();
// 入参类型强制为 { id: number },传字符串直接编译报错
const one = await trpc.user.getById.query({ id: 2 });

如果你用 React,官方推荐配合 @trpc/react-query 使用,调用变成 trpc.user.list.useQuery(),自动获得缓存、重试与 loading 状态,体验与 React Query 完全一致,但多了一层端到端类型保护。

四、与 Zod 联用:入参校验不缺位

tRPC 的每个 procedure 都用 z(Zod)声明 input,请求进来时自动校验。这把我们在 Zod 运行时类型校验 一文里强调的”边界守卫”理念落到了框架层:非法入参根本进不了业务逻辑。下面对比一下手写校验与 Zod 的差异:

维度手写 if 校验Zod + tRPC
类型同步需手写 interface,易漂移schema 即类型,自动推断
错误提示自己拼字符串error.format() 结构化
维护成本改两处改一处
边界覆盖易遗漏字段声明即强制
// 复杂入参也能一句话声明
const createPost = publicProcedure
  .input(z.object({
    title: z.string().min(1).max(100),
    tags: z.array(z.string()).max(5),
    draft: z.boolean().default(false),
  }))
  .mutation(async ({ input }) => {
    // input 已被校验且类型安全
    return { ok: true, id: Date.now() };
  });

五、在 Next.js 中落地:全栈一体的 BFF

在 Next.js 与 React Server Components 架构里,tRPC 通常作为 BFF(Backend For Frontend)层。用 App Router 的 Route Handler 暴露一个统一的 /trpc 入口即可:

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/trpc";

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: "/api/trpc",
    req,
    router: appRouter,
    createContext: () => ({}),
  });

export { handler as GET, handler as POST };

Server Component 里可以直接 await trpc.user.list.query() 取数,类型同样端到端安全;需要客户端交互的部分再交给 React Query 适配器。这样一套类型贯穿数据库、服务端、前端,是 TypeScript 全栈最顺滑的写法之一。

六、工程化:容器化与类型校验

把 tRPC 服务用 Docker 编排 打包,并在 GitHub Actions 里做类型检查,能防止”本地能跑、CI 崩了”的类型漂移。一个最小多阶段 Dockerfile:

# Dockerfile
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=base /app/node_modules ./node_modules
COPY --from=base /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]
# .github/workflows/ci.yml
name: ci
on: [push]
jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      - run: npm run typecheck   # tsc --noEmit,守住端到端类型

七、常见坑与排查

现象根因解法
前端类型不更新没导出 / 没重新推导 AppRouter确认 export type AppRouter,前端重跑 tsc
入参 400 报错Zod 校验失败看 error.format(),检查字段名与类型
浏览器跨域缺 CORS 头在 adapter 外层加 CORS 中间件
nuqs / 序列化异常入参含 Date 等不可序列化值用 z.coerce 或 transform 归一

八、tRPC 适合谁?与 REST / gRPC 怎么选

tRPC 不是银弹。下面这张表帮你在技术选型时快速判断。无论你是用 Vue3 还是 React,只要前后端都是 TypeScript,tRPC 的体验都一致;配合 高效的开发工具链,编辑器里的类型补全会让你彻底告别”猜接口”。

维度tRPCRESTgRPC
类型安全端到端自动需手写/生成强(protobuf)
多语言限 TS 两端任意任意
调试友好度中(JSON over HTTP)高(curl 即可)低(二进制)
适用场景TS 全栈内部 API对外开放 API内部高性能 RPC

经验法则:对外暴露给第三方、需要多语言调用的接口,用 REST 或 gRPC;团队内部的 TS 全栈服务,尤其是 BFF 与中后台,tRPC 能把类型红利吃到极致。两者并不互斥,很多项目用 tRPC 做内部、REST 做对外网关。

九、总结

tRPC 的价值不在”又一种 RPC”,而在于它把类型安全 API做到了极致:一份 router 定义,编译期给前端类型、运行期给入参校验,省掉了 OpenAPI 与代码生成两套负担。配合 Zod 在边界做运行时守卫,再放进 Docker 与 CI 的类型检查流水线,你就能用最小的维护成本,守住全栈 TypeScript 项目的类型契约。对于追求开发效率与健壮性的 TS 团队,tRPC 值得作为内部 API 的默认选项。

上一篇 mise 实战:用 Rust 工具统一管理多语言版本与开发任务
下一篇 Apache Iceberg 实战:数据湖表格式与海量数据分析
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]