Create API Contracts (GraphQL, tRPC, oRPC) and Workarounds

打造前後端 API 資料合約(GraphQL、tRPC、oRPC)與相關變通方案

前言

使用 TypeScript 確保程式型別安全以維持大型前端專案的品質與維護性已經是成熟常見的做法,但有一項破口會讓程式變得脆弱且混亂:「程式間 API 溝通」。

TypeScript 本身雖然能靜態檢查程式內部的型別正確性,但 API 來自外部,任何意料之外的資料都可能被拋過來,只能在程式執行階段驗證資料格式才能真正確保型別安全。

現階段問題

通常前端開發者需要仰賴外部文件像是 Swagger🔗OpenAPI🔗 等來了解 API 格式,並自己在前端描述資料格式,最簡單就是斷言資料型別。

// 1. 定義型別
type User = {
id: number
name: string
email: string
}
// 2. fetch 呼叫 API
async function fetchUser(userId: number): Promise<User> {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
// 3. 將 response.json() 的結果斷言成 User
const data = (await response.json()) as User
return data
}

很大的問題在於前後端對於 API 格式都仰賴「信任」,但只要回應不如預期例如都會讓程式不知不覺的於執行時產生錯誤或影響開發體驗:

  • 後端改動導致回應改變
  • 前端偷懶或趕時間跳過定義型別
  • 錯字

另外一個問題是就算以上問題都沒有發生,前後端仍存在 資料格式定義重複的問題,同樣的資料需要前後端反覆進行描述以確保型別安全。

為了解決以上問題,最好程式之間溝通可以有某種強制約束性且統一的合約,定義一份資料格式供雙方通用。

GraphQL

GraphQL Schema🔗 透過自動化代碼生成與型別檢查,讓前後端共享一份強制約束的合約。

以下範例:定義資料 → 索取資料 → 拿到資料

type Project {
name: String
tagline: String
contributors: [User]
}
{
project(name: "GraphQL") {
tagline
}
}
{
"project": {
"tagline": "A query language for APIs"
}
}
  • 優點:
    • 統一通用 Schema 定義,與特定程式語言無關,因此跨語言友好。
    • 豐富的工具鏈(型別產生、自動文件化、Playground)。
    • 解決 overfetching 和 underfetching 問題。
  • 缺點:
    • 像是快取、複雜度、概念上與傳統 REST 完全不同,可能對舊有系統和習慣有極大的負擔。
    • 查詢複雜度管理困難(容易過度查詢、N+1 問題)。

RPC

RPC(Remote Procedure Call,遠端程序呼叫) 是一種通訊方式,讓前端像呼叫本地函式一樣去呼叫後端方法。 例如,前端只需要呼叫 getUserById(123),背後會自動傳送請求到後端的 getUserById 方法,並把回應結果返回,不需要手動處理 HTTP request/response 的細節。

tRPC

讓前後端透過 TypeScript 緊密合作的 RPC 框架

以下範例:client 呼叫 → 發 HTTP 請求 → server 找到 procedure → 回傳 JSON

const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({ url: 'http://localhost:3000/trpc' }),
],
});
await trpc.post.create.mutate({ title: "Hello" }); // 背後實際發送請求 POST http://localhost:3000/trpc/post.create
const appRouter = t.router({
post: t.router({
create: t.procedure.input(z.object({ title: z.string() }))
.mutation(({ input }) => db.post.create(input)),
}),
});
  • 優點:
    • 與 TypeScript 型別系統深度整合。
    • 前後端共用一份 API 定義,不需要額外維護文件或型別。
  • 缺點
    • 像是快取、複雜度、概念上與傳統 REST 完全不同,可能對舊有系統和習慣有極大的負擔。
    • client 與 server 綁定在一起耦合度高不易拆分。
    • 強烈綁定 TypeScript 型別系統,共用相同的 TypeScript 專案結構。

oRPC

讓前後端透過 OpenAPI 緊密合作的 RPC 框架
openapi.yaml
paths:
/users/{id}:
get:
operationId: getUser
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
properties:
id:
type: integer
name:
type: string
// 透過 codegen 自動生成
import { getUser } from "./api-client";
const user = await getUser({ id: 123 });
// user 型別會自動對應 OpenAPI 的 User
  • 優點:

    • 使用 OpenAPI 標準,跨語言支援良好。
    • 可透過工具鏈自動產生型別與文件。
    • API 合約獨立於實作。
  • 缺點:

    • 相較 tRPC 缺乏 TypeScript 即時推導的便利性。
    • codegen 過程可能需要額外維護(例如 CI/CD 流程整合)。
    • 社群生態仍在發展中。

變通方法

Runtime 驗證

如果沒辦法透過以上方式統一前後端型別,我們仍然可以在前端主動進行 Runtime 檢測,透過 zod🔗 這類驗證器來確保型別安全。

const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
const data = await response.json();
const parsed = UserSchema.parse(data);

共用型別

如果前後端共用相同語言(TypeScript)可以將共用型別抽出成獨立套件或組成 Monorepo、Monolith 架構,直接共享型別定義。

延伸閱讀