tRPC 实战:端到端类型安全、Zod 校验与 React Query 集成

前端工程

tRPC 为什么值得用

传统前后端协作靠「手写 API 文档 + 各自维护类型」,类型一改就容易出现前后端不一致。tRPC 的核心卖点是:你的后端过程(procedure)就是类型来源,前端调用时自动获得完整类型推导,无需 codegen、无需 schema 文件

方案 类型来源 是否需要 codegen 跨语言
REST + 手写类型 人工维护
GraphQL + codegen schema 文件
tRPC 后端过程直接推导 否(限定 TS/TS)

定义 Router 与 Procedure

// 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
export const userRouter = router({
  byId: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(({ input }) => {
      return db.user.findUnique({ where: { id: input.id } });
    }),
  create: publicProcedure
    .input(z.object({ name: z.string().min(1), email: z.string().email() }))
    .mutation(({ input }) => {
      return db.user.create({ data: input });
    }),
});

query 对应读、mutation 对应写。输入用 Zod 声明,tRPC 会在运行时自动校验。

复杂入参结构(嵌套对象)可先用 JSON 格式化 工具梳理,再转成 Zod schema,避免字段遗漏。


与 React Query 集成

这是最经典的组合:tRPC 负责类型,React Query 负责缓存/重试/失效。

// client/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../server/routers";

export const trpc = createTRPCReact<AppRouter>();

function UserList() {
  const users = trpc.user.list.useQuery();
  const utils = trpc.useUtils();
  const create = trpc.user.create.useMutation({
    onSuccess: () => utils.user.list.invalidate(),
  });

  if (users.isLoading) return <p>加载中…</p>;
  return (
    <ul>
      {users.data?.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
      <button onClick={() => create.mutate({ name: "Ada", email: "a@x.com" })}>
        新增
      </button>
    </ul>
  );
}

注意 users.data 的类型、以及 create.mutate 的参数类型,全部由后端过程推导,改了后端入参前端立刻报错。


中间件与鉴权

import { initTRPC, TRPCError } from "@trpc/server";

const t = initTRPC.context<{ jwt?: string }>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.jwt) throw new TRPCError({ code: "UNAUTHORIZED" });
  return next({ ctx: { ...ctx, userId: verify(ctx.jwt) } });
});

export const protectedProcedure = t.procedure.use(isAuthed);

中间件可以改写 ctx,下游 procedure 就能拿到 userId

校验 JWT 时可用 JWT 解码 工具查看 payload 中的 sub 与过期时间,快速核对鉴权链路。


错误处理

tRPC 用 TRPCError 统一错误,前端通过 error.data.code 区分:

protectedProcedure.query(({ ctx }) => {
  const u = db.user.findUnique({ where: { id: ctx.userId } });
  if (!u) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
  return u;
});

前端:

const { error } = trpc.user.me.useQuery();
if (error?.data?.code === "NOT_FOUND") return <p>未找到</p>;

错误码与 HTTP 状态码一一对应,排查时可用 HTTP 状态码 工具对照 UNAUTHORIZED=401NOT_FOUND=404 等。


Subscriptions(实时)

tRPC 支持基于 WebSocket 的订阅:

export const appRouter = router({
  onMessage: publicProcedure.subscription(({ signal }) => {
    return observable<Message>((emit) => {
      const timer = setInterval(() => emit.next({ text: "ping" }), 1000);
      return () => clearInterval(timer);
    });
  }),
});

从 REST 平滑迁移

  1. 梳理边界:把「读」映射为 query、「写/副作用」映射为 mutation
  2. 补 Zod:给每个接口加输入 schema,等于顺手补了运行时校验。
  3. 替换客户端:用 trpc.xxx.useQuery/useMutation 替代 fetch + 手写类型。
  4. 鉴权下沉:把分散的 JWT 检查收敛到中间件 protectedProcedure
  5. 保留 REST:tRPC 也可通过 fetch adapter 暴露 HTTP 端点,渐进迁移。

常见问题 FAQ

Q1:tRPC 能跨语言吗?

不能。它依赖 TypeScript 类型推导,前后端都必须是 TS。若需多语言,应回到 REST/GraphQL。

Q2:和 GraphQL 比有什么优势?

免去 schema 文件与 codegen,类型即源码。代价是失去跨语言与公共 API 能力。

Q3:Zod 校验失败返回什么?

自动转为 BAD_REQUEST 错误,前端 error.data.code === "BAD_REQUEST",并携带 Zod 问题列表。

Q4:能不用 React Query 吗?

可以,tRPC 核心与客户端无关,也有原生 client 调用方式,但 React Query 集成体验最佳。

Q5:大列表分页怎么写?

input 里加 cursor/limit,用 query 返回 { items, nextCursor },前端配合 useInfiniteQuery


工具推荐

在 tRPC 全栈开发中,以下 工具库 能帮上忙:


tRPC 不是「又一个 RPC 框架」,而是一套「类型即契约」的开发范式:后端过程直接成为前端的类型来源,省掉文档、codegen 与两边对字段的心智负担。当你改了后端入参,编辑器立刻在前端点红——这种端到端的类型安全,正是现代全栈 TS 团队的甜蜜点。

本站提供浏览器本地工具,免注册即可试用 →

#tRPC#类型安全#全栈#API#Zod#React Query#前端