api contract orpc typescript frontend

用 oRPC 打造前端良好的 API 體驗

莊政哲 Peter Chuang 2026/07/27 12:37:57
23

前言

大家應該都有遇過,即使用 TypeScript 寫好 interface ,過了一陣子系統畫面突然炸掉,你花時間檢查後才發現是 API 欄位有修改。你意識到 TypeScript 能做到編譯期間的檢查,卻檢查不到伺服器 runtime 實際回傳什麼。

這篇用 oRPC 講前端怎麼把 API 從型別改成可共用的契約,再從契約做出有型別的呼叫。

甚麼是契約?:

契約就是前後端共用一份規格:包含路徑、輸入、輸出、錯誤、資訊。

這篇會回答你三個問題:

  1. 為什麼前端自己維護的型別,救不了前後端對不上?
  2. 契約長什麼樣子?前端怎麼從它拿到有型別的呼叫?
  3. 契約接上前端日常之後長怎樣?後端根本不是 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 型別,只能在編譯期間做保護,上線後會怎樣你不知道。
  • 契約是前後端共用的規格,包括路徑、輸入、輸出、錯誤、資訊。需要具名型別時,用 InferContractRouterInputsOutputs 從同一份契約抽。
  • 一條龍用法:建立契約 ➡️ OpenAPI client ➡️ Pinia Colada / TanStack Query。
  • 後端不是 TypeScript?透過 OpenAPI spec 轉成契約。在 interceptor 加一層 output 驗證,確保 runtime 有把關。

參考

  1. oRPC
莊政哲 Peter Chuang