用 oRPC 打造前端良好的 API 體驗
前言
大家應該都有遇過,即使用 TypeScript 寫好 interface ,過了一陣子系統畫面突然炸掉,你花時間檢查後才發現是 API 欄位有修改。你意識到 TypeScript 能做到編譯期間的檢查,卻檢查不到伺服器 runtime 實際回傳什麼。
這篇用 oRPC 講前端怎麼把 API 從型別改成可共用的契約,再從契約做出有型別的呼叫。
甚麼是契約?:
契約就是前後端共用一份規格:包含路徑、輸入、輸出、錯誤、資訊。
這篇會回答你三個問題:
- 為什麼前端自己維護的型別,救不了前後端對不上?
- 契約長什麼樣子?前端怎麼從它拿到有型別的呼叫?
- 契約接上前端日常之後長怎樣?後端根本不是 TypeScript 時還能靠契約嗎?
為什麼前端型別救不了對不上?
先假設一個情境。你在寫文章詳情頁,後端給了 GET /posts/:id,你很負責任地寫了:
interface Post {
id: number
title: string
body: string
}
async function getPost(id: number): Promise<Post> {
const res = await fetch(`/api/posts/${id}`)
return res.json()
}
部屬上線一切順利,但直到某天後端把 body 改成 content,或把 id 改成字串,後端這時候沒通知你,這時候型別幫不到你,你只能無奈地收下這張 bug 單自己找原因。
這實際上就是前後端各維護一份型別各維護一份「型別」的成本,沒有「同步」機制的話就只能靠「人工」來約束,但往往上面的情節會一再重演。
那 oRPC 是怎麼解決的?
那份東西就叫做契約(contract)。
契約長什麼樣子?
契約能寫的不只是「進出資料長什麼樣子」。路徑、輸入、輸出、錯誤、資訊(meta)這些,都可以先約定好。
以「文章」功能來舉例:
import { oc } from '@orpc/contract'
import { z } from 'zod'
export const PostSchema = z.object({
id: z.number().int().min(1),
title: z.string(),
body: z.string(),
})
export const findPostContract = oc
.route({
method: 'GET',
path: '/posts/{id}',
summary: '取得單篇文章',
})
.errors({
NOT_FOUND: {
message: 'Post not found',
},
})
.input(z.object({ id: z.number().int().min(1) }))
.output(PostSchema)
export const listPostsContract = oc
.route({
method: 'GET',
path: '/posts',
summary: '列出文章',
})
.input(
z.object({
limit: z.number().int().min(1).max(50).optional(),
}),
)
.output(z.array(PostSchema))
export const contract = {
post: {
find: findPostContract,
list: listPostsContract,
},
}
這段程式碼跟「寫一個會跑的 API handler」是兩回事:從頭到尾都只有寫 API 的形狀而已。
實際每一段在做甚麼:
.route(...):這支 API 走哪個 HTTP method、哪個 path;summary這類欄位則是給文件/工具看的資訊.errors(...):這支可能丟出哪些已知錯誤(之後 client 端錯誤也能跟著有型別).input(...)/.output(...):進出資料的形狀
而 z.object(...) 就是用 zod 來當作 schema。一兼二顧:TypeScript 靠它推型別,runtime 又能拿它驗證資料是否合法。這裡雖然用的是 Zod,但你用其他 Standard Schema 的驗證器也 OK,例如: Valibot 或 ArkType。
你可能會想說:「這跟我自己寫 interface 有什麼差?還不是前端在寫規格?」差在位置。interface 通常只活在前端 repo 裡。而契約一開始就是拿來分享的:放進一個兩邊都裝得到的 package (例如 monorepo),後端實作還沒寫完也無妨,雙方可以先訂好規格。重點是「同一份」,前後端都按照這份契約開發的話就不會不同步了。
前端怎麼從契約拿到有型別的呼叫?
有了 contract 以後要怎麼連到 API 伺服器和呼叫呢?oRPC 拆成兩件事:link 負責描述怎麼連到伺服器,而契約負責標註 client 的型別。
前面契約已經寫了 GET /posts/{id} 這種 path。那 client 呼叫 client.post.find({ id: 1 }) 時,誰負責把它翻譯成真正的 HTTP request?
這時候用 OpenAPILink:會按照 OpenAPI 風格發出請求。
import type { ContractRouterClient } from '@orpc/contract'
import { createORPCClient } from '@orpc/client'
import { OpenAPILink } from '@orpc/openapi-client/fetch'
import { contract } from './contract'
const link = new OpenAPILink(contract, {
url: 'http://localhost:3000/api',
})
export const client: ContractRouterClient<typeof contract> = createORPCClient(link)
用起來很方便吧,就像有人幫你定義好每個呼叫函式:
const post = await client.post.find({ id: 1 })
// 實際發出: GET /api/posts/1
// post.title、post.body 都有型別
const posts = await client.post.list({ limit: 10 })
// 實際發出: GET /api/posts?limit=10
若你傳錯 input,例如 id: '1',TypeScript 也會幫你做檢查。
要怎麼取得型別?
一樣可以從合約來:
import type {
InferContractRouterInputs,
InferContractRouterOutputs,
} from '@orpc/contract'
import type { contract } from './contract'
type Inputs = InferContractRouterInputs<typeof contract>
type Outputs = InferContractRouterOutputs<typeof contract>
type Post = Outputs['post']['find']
// { id: number; title: string; body: string }
function PostCard(props: { post: Post }) {
return props.post.title
}
Inputs['post']['find'] 同理,會得到 { id: number }。重點仍是「同一份」:契約改型別跟著改。
後端怎麼運用契約?
oRPC 在 server 端用 implement(contract) 把契約變成實作起點;handler 的 input/output 必須對得上,組 router 時也會被檢查。
import { implement } from '@orpc/server'
import { contract } from './contract'
const os = implement(contract)
export const findPost = os.post.find.handler(({ input }) => {
// 必須回傳符合 PostSchema 的形狀
return { id: input.id, title: 'Hello', body: '...' }
})
export const router = os.router({
post: {
find: findPost,
// list: ...
},
})
現在你明白契約是怎麼約束前後端了吧。
契約到狀態管理一條龍
契約解決了「呼叫時的型別從哪來」。但前端還是要一套 loading、錯誤、快取、重新驗證的狀態管理方案。
若你走的是 Vue/Pinia 生態,可以交給 Pinia Colada。如果需要跨框架(React、Vue、Angular ),推薦使用 TanStack Query。
實際應用上你可能會這樣寫:
useQuery({
key: () => ['post', id.value],
query: () => client.post.find({ id: id.value }),
})
寫起來就是沒那麼直覺和不好管理。oRPC 的 @orpc/vue-colada 可以把從契約長出來的 client,接進 Colada 的 options:
import { createORPCVueColadaUtils } from '@orpc/vue-colada'
import { useQuery } from '@pinia/colada'
import { client } from './client'
export const orpc = createORPCVueColadaUtils(client)
在元件裡就可以這樣用:
const id = ref(1)
const { data, status } = useQuery(
orpc.post.find.queryOptions({
input: { id: id.value },
}),
)
// data 的型別來自契約的 output
// status、error 等仍是你熟悉的 Pinia Colada 介面
// key 也是自動生成的
型別、呼叫、狀態管理都從契約來。只要改動一份契約,剩下的 TypeScript 會告訴你——這就是 One for All。
後端不是 TypeScript 怎麼辦?
「講了半天,後端是 Go/Java/PHP 怎麼辦?難道契約只能活在 TS monorepo?」
不一定。只要後端產出 OpenAPI spec,前端拿那份 spec 轉成可以直接轉契約或 client。「同一份」的來源這時就變成 spec 本身。
要是 OpenAPI spec 也沒有怎麼辦?
實務上常常還是「只有前端在吃這份契約」:後端也許是另一個團隊、另一種語言,也沒有辦法提供 OpenAPI spec。
這時要記得在 link 的 interceptor 裡,拿契約的 output schema 再驗一次 response。對不上就拋錯,別讓錯誤資料有機會出現在畫面:
import type { ContractRouterClient } from '@orpc/contract'
import { createORPCClient, ORPCError } from '@orpc/client'
import { isContractProcedure } from '@orpc/contract'
import { OpenAPILink } from '@orpc/openapi-client/fetch'
import { contract } from './contract'
function getByPath(obj: unknown, path: readonly string[]) {
return path.reduce<unknown>((acc, key) => {
if (acc && typeof acc === 'object' && key in acc) {
return (acc as Record<string, unknown>)[key]
}
return undefined
}, obj)
}
const link = new OpenAPILink(contract, {
url: 'http://localhost:3000/api',
interceptors: [
async (options) => {
const result = await options.next()
const procedure = getByPath(contract, options.path)
if (!isContractProcedure(procedure)) {
return result
}
const outputSchema = procedure['~orpc'].outputSchema
if (!outputSchema) {
return result
}
const validated = await outputSchema['~standard'].validate(result)
if ('issues' in validated && validated.issues) {
throw new ORPCError('OUTPUT_VALIDATION_FAILED', {
status: 502,
message: 'Contract output mismatch',
data: {
path: options.path.join('.'),
issues: validated.issues,
response: result,
},
})
}
return validated.value
},
],
})
export const client: ContractRouterClient<typeof contract> = createORPCClient(link)
這樣做的意思是至少你還是有在 runtime 做驗證。當 API 形狀有變時,你會得明確的錯誤,就不用花時間查。
總結
- 前端自己維護的 TypeScript 型別,只能在編譯期間做保護,上線後會怎樣你不知道。
- 契約是前後端共用的規格,包括路徑、輸入、輸出、錯誤、資訊。需要具名型別時,用
InferContractRouterInputs/Outputs從同一份契約抽。 - 一條龍用法:建立契約 ➡️ OpenAPI client ➡️ Pinia Colada / TanStack Query。
- 後端不是 TypeScript?透過 OpenAPI spec 轉成契約。在 interceptor 加一層 output 驗證,確保 runtime 有把關。
