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#前端