昨天我們透過 Orval 串起 API Contract,了解如何根據 OpenAPI 規格產生前端需要的型別與 API 呼叫方式。
接下來延續這個基礎,看看後端採用「統一回覆格式」時,Response 可以怎麼設計,以及這種格式要如何和 Orval 搭配。
實務上,後端常會把 API Response 統一包成固定格式,讓前端能用一致的方式判斷請求結果,以及不同狀態碼或業務代碼代表的情況。
例如每支 API 都維持 code、message、data 這幾個固定欄位,前端就不需要針對不同 API 各自理解不同的回傳結構。
code 表示操作成功,實際資料放在 data 中。{
"code": "SUCCESS",
"message": "取得成功",
"data": {
"id": 1,
"name": "Antonio"
}
}
code 表示失敗原因,再搭配 message 提供對應的錯誤訊息。{
"code": "USER_NOT_FOUND",
"message": "查無此使用者",
"data": null
}
這樣前端就能用固定的結構處理 Response,再根據不同的 code 決定後續邏輯。
不過,如果這套格式只存在於後端實作中,而 OpenAPI 文件仍然只描述原本的資料內容,那麼 Orval 產生的型別就會和實際收到的 Response 不一致。
如果後端使用 NestJS,可以透過自定義 Decorator,把統一回覆格式一起描述到 OpenAPI:
@ApiWrappedResponse() 指定 data 的實際型別。@ResponseMessage() 設定回應中的 message。@ApiWrappedErrorResponse() 描述不同 HTTP Status 對應的錯誤格式。以取得待辦事項清單為例:
@Get()
@ApiOperation({
summary: '取得所有待辦事項',
operationId: 'listTodos',
})
@ApiWrappedResponse(Todo, { isArray: true })
@ResponseMessage('取得成功')
findAll(): Todo[] {
return this.todosService.findAll();
}
Controller 實際回傳的仍然只是 Todo[],{ code, message, data } 這層統一格式,則由 Interceptor 在 Response 送出前統一包裝。
這些 Decorator 不會改變 Controller 真正回傳的資料,它們的用途是讓 OpenAPI 文件能描述「包裝完成後」的 Response 結構。
把後端一路到前端的流程攤開來看,大致會像這樣:

因為 OpenAPI 描述的是包好外層之後的格式,所以 Orval 產生的型別,例如圖中的 ListTodos200Output,也會保留 code、message、data 這三個欄位,而不是直接產生 Todo[]。
到了前端,可以再由 Service 把這層 Response 外殼拆掉,只把畫面需要的 data 與 Resource 狀態提供給 Component。
這樣 Component 不需要每支 API 都重複處理 { code, message, data },也不用知道統一回覆格式是怎麼產生的。
除了成功回應之外,前端通常也會透過 Interceptor 或共用的錯誤處理機制,處理不同層級的失敗情況:
code 判斷實際錯誤。這三種錯誤可以整理成下面的流程:

把不同層級的錯誤分開處理後,Component 就不需要負責所有錯誤判斷,只要處理真正和 UI 有關的狀態。
這樣也能讓各層職責更清楚。Service 負責整理成功回應並取出畫面需要的資料,而連線錯誤、API 錯誤與 Runtime Validation Error,則交給對應的共用錯誤處理機制。
有些 API Request 之間會存在先後依賴關係。
例如 B 請求需要 A 請求回傳的資料才能發出,就不能一開始同時送出兩個 Request,而是要先等 A 完成,再根據 A 的結果決定 B 的參數。
這類情境可以搭配 httpResource() 的 chain 功能,讓後面的 Resource 直接依賴前一個 Resource。
例如先取得使用者資料,再根據使用者的 companyId 取得公司資料:
interface User {
id: number;
name: string;
companyId: number;
}
interface Company {
id: number;
name: string;
}
userResource = httpResource<User>(
() => `/api/users/${this.userId()}`
);
companyResource = httpResource<Company>(({ chain }) => {
const user = chain(this.userResource);
return `/api/companies/${user.companyId}`;
});
這裡 companyResource 不會一開始就發出 Request,而是會先等待 userResource 取得資料。
整個流程大致會像這樣:

chain 和直接讀取 userResource.value() 的差別,不只在於取得前一個 Resource 的資料,還會把上游 Resource 的狀態一起帶進這段依賴關係。
當上游還在載入、重新載入或發生錯誤時,下游不會繼續發出自己的 Request;只有在上游有可使用的值時,才會繼續往下執行。

依照上游狀態,可以分成幾種情況:
idle:companyResource 也會進入 idle,params function 不會繼續執行。loading / reloading:companyResource 會進入 loading,但不會發出自己的 Request。即使上游正在重新載入,chain() 也不會先拿前一次的舊值繼續往下執行。error:companyResource 也會進入 error,後續 Request 不會發出。resolved / local:chain() 才會回傳目前的值,讓 params function 繼續執行,接著根據 companyId 發出下一個 Request。補充:
local表示 Resource 的值曾透過set()或update()在前端直接修改。
當 chain() 正在傳遞 idle、loading、reloading 或 error 狀態時,params function 會停在 chain() 這裡,不會繼續執行後面的程式。
例如:
companyResource = httpResource<Company>(({ chain }) => {
const user = chain(this.userResource);
return `/api/companies/${user.companyId}`;
});
只有在 userResource 進入 resolved 或 local 狀態後,才會繼續執行:
return `/api/companies/${user.companyId}`;
另外,即使上游 Resource 已經進入 resolved 或 local,目前的 value() 仍然可能是 undefined。
如果這種情況本來就是允許的,可以在下游 Request 建立前先做判斷:
companyResource = httpResource<Company>(({ chain }) => {
const companyId = chain(this.userResource)?.companyId;
return companyId !== undefined
? `/api/companies/${companyId}`
: undefined;
});
這種狀態傳遞也讓畫面處理簡單很多。面對一串彼此相依的請求,可以主要觀察最後一個 Resource:
@if (companyResource.isLoading()) {
<p>載入中...</p>
}
@if (companyResource.error()) {
<p>取得資料失敗</p>
}
@if (companyResource.hasValue()) {
<p>{{ companyResource.value().name }}</p>
}
不需要再自己把 userResource 和 companyResource 的 loading、error 狀態另外組合起來。
這種依賴關係也不只發生在第一次載入。
例如當 userId 改變:
this.userId.set(2);
userResource 會先根據新的 userId 重新取得資料,等新的使用者資料回來後,companyResource 才會使用新的 companyId 再發出 Request:
因此 chain 建立的不是單純「先做 A,再做 B」的一次性流程,而是一段會跟著上游資料與狀態變化重新執行的非同步依賴關係。
如果只是根據 A 的資料同步計算另一個值,並不需要再發出非同步請求,就不需要使用 chain,直接用 computed() 會更合適。
今天的兩個主題看起來一前一後,其實在處理同一件事:讓資料的「形狀」與「狀態」在源頭就被說清楚,下游只要跟著走。
統一回覆格式的關鍵不在 { code, message, data } 這個結構本身,而在於它有沒有進到 API Contract。只要 OpenAPI 描述的是真實的 Response,從 Orval 產生的型別、Service 拆殼到錯誤分層,才有同一份可靠的依據。
chain 則把同樣的想法用在非同步流程上:上游的狀態,就是下游能不能開始的前提。不過這也代表上游失敗時,下游一定會跟著失敗。如果下游只是「有更好、沒有也能顯示」的補充資訊,例如拿不到公司資料時仍要照常顯示使用者,就不適合用 chain 把錯誤一路帶下來,讓兩個 Resource 各自呈現狀態會更合適。
reloading 時不回傳舊值、上游值可能是 undefined,以及純同步計算改用 computed()。@ApiOperation() 與回應描述,本文的自定義 Decorator 建立在這之上。{ code, message, data }。