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=401、NOT_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 平滑遷移
- 梳理邊界:把「讀」對應為
query、「寫/副作用」對應為mutation。 - 補 Zod:給每個介面加輸入 schema,等於順手補了執行期校驗。
- 替換客戶端:用
trpc.xxx.useQuery/useMutation替代fetch+ 手寫型別。 - 鑑權下沉:把分散的 JWT 檢查收斂到中介層
protectedProcedure。 - 保留 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 全堆疊開發中,以下 工具庫 能幫上忙:
- JSON 格式化 — 梳理入參/出參結構再寫 Zod
- JWT 解碼 — 核對鑑權 Token 的 sub 與過期時間
- HTTP 狀態碼 — 對照 tRPC 錯誤碼與 HTTP 語意
- Base64 編碼 — 處理憑據與二進位載荷
tRPC 不是「又一個 RPC 框架」,而是一套「型別即契約」的開發範式:後端過程直接成為前端的型別來源,省掉文件、codegen 與兩邊對欄位的心智負擔。當你改了後端入參,編輯器立刻在前端點紅——這種端到端的型別安全,正是現代全堆疊 TS 團隊的甜蜜點。
本站提供瀏覽器本地工具,免註冊即可試用 →