TypeScript – 異步與 Promise 型別
主題:Awaited<T> 工具型別
簡介
在 TypeScript 中處理非同步程式碼時,Promise 及 async/await 已經是日常必備的工具。隨著專案規模的擴大,我們常常需要在型別層面「抽取」Promise 內部解析後的值,以便在函式簽名、泛型工具或型別推斷時取得正確的結果。Awaited<T> 正是為了這個需求而生的 內建工具型別(Utility Type),它可以把任意類型 T 遞迴 地「解包」成最終的非 Promise 值。掌握 Awaited<T>,不只讓型別更精確,也能避免因手動寫 T extends Promise<infer R> 而產生的錯誤與維護成本。
本篇文章將從概念說明、實作範例、常見陷阱,到實務應用逐步帶你了解 Awaited<T>,即使是剛接觸 TypeScript 的開發者,也能快速上手。
核心概念
1. Awaited<T> 的基本定義
TypeScript 4.5 之後,官方在 lib.es2022.promise.d.ts 中加入了以下宣告:
type Awaited<T> = T extends null | undefined
? T
: T extends object & { then: infer F }
? F extends (onfulfilled: infer V, ...) => any
? Awaited<V>
: never
: T;
簡單來說:
| 來源類型 | Awaited<T> 產生的結果 |
|---|---|
Promise<U>、Thenable<U> |
會遞迴取出 U,直到不是 Promise 為止 |
null、undefined |
保持原樣 |
其他非 Promise 類型 |
直接返回原型別 |
重點:
Awaited<T>會遞迴解包,這意味著Promise<Promise<string>>會直接得到string。
2. 為什麼不直接使用 infer?
在早期版本,我們常透過以下方式自行實作「解包」型別:
type UnwrapPromise<T> = T extends Promise<infer R> ? R : T;
然而這樣的寫法只能解開 單層 Promise,若遇到 Promise<Promise<number>>,結果仍會是 Promise<number>,而非最終的 number。Awaited<T> 內建了 遞迴 機制,讓開發者不必自行處理多層嵌套,且同時支援 Thenable(如 fetch 回傳的 Response 內部的 body)以及 null/undefined 的特殊情況。
3. Awaited<T> 在泛型函式中的應用
以下示範一個接受任意 非同步 回傳值的函式,透過 Awaited<T> 取得最終型別,讓呼叫端得到正確的型別推斷。
async function fetchData<T>(promise: T): Promise<Awaited<T>> {
// 直接回傳 Promise,TypeScript 會自動套用 Awaited 的結果
return await promise as Awaited<T>;
}
// 使用範例
const num = await fetchData(Promise.resolve(42)); // num: number
const str = await fetchData(Promise.resolve(Promise.resolve('hi'))); // str: string
在 fetchData 中,我們不需要手寫 T extends Promise<infer R>,只要把回傳型別寫成 Promise<Awaited<T>>,即可自動支援任意深度的 Promise 包裝。
4. Awaited<T> 與條件型別的結合
有時我們會根據傳入的型別決定不同的行為,這時可以利用 Awaited<T> 讓條件更直觀:
type ApiResult<T> = Awaited<T> extends { data: infer D }
? { success: true; payload: D }
: { success: false; error: string };
// 範例
type Res1 = ApiResult<Promise<{ data: number }>>; // { success: true; payload: number }
type Res2 = ApiResult<string>; // { success: false; error: string }
透過 Awaited<T>,ApiResult 能正確辨識「非同步回傳」與「同步回傳」的差異,讓型別描述更貼近實際 API 設計。
程式碼範例
以下提供 5 個實用範例,從最基礎到進階,幫助你在不同情境下運用 Awaited<T>。
範例 1:簡單解包單層 Promise
type A = Awaited<Promise<string>>; // => string
說明:直接把
Promise<string>轉成string,相當於await後的結果型別。
範例 2:遞迴解包多層 Promise
type B = Awaited<Promise<Promise<number>>>; // => number
type C = Awaited<Promise<Promise<Promise<boolean>>>>; // => boolean
說明:即使巢狀三層,
Awaited仍會一路「拆」到最底層。
範例 3:支援 Thenable(自訂的非 Promise 物件)
class MyThenable<T> {
constructor(private value: T) {}
then(onfulfilled: (v: T) => any) {
return onfulfilled(this.value);
}
}
type D = Awaited<MyThenable<Date>>; // => Date
說明:只要物件符合
then方法的形態,Awaited就會把它視為 thenable,同樣解包。
範例 4:與 null / undefined 共存
type E = Awaited<Promise<string> | null>; // => string | null
type F = Awaited<undefined>; // => undefined
說明:
null與undefined不會被「吞掉」,保持原樣,避免意外的型別收斂。
範例 5:在函式型別中使用 Awaited 結合泛型
// 一個通用的 async mapper
async function asyncMap<T, U>(arr: T[], fn: (item: T) => Promise<U> | U): Promise<Awaited<U>[]> {
const results = await Promise.all(arr.map(fn));
// 這裡的 results 已經是 Awaited<U>[]
return results as Awaited<U>[];
}
// 使用範例
const nums = [1, 2, 3];
const doubled = await asyncMap(nums, n => Promise.resolve(n * 2)); // doubled: number[]
const mixed = await asyncMap(nums, n => (n % 2 === 0 ? Promise.resolve(`${n}`) : n)); // mixed: (number | string)[]
說明:
Awaited<U>讓asyncMap能同時接受同步與非同步的回傳值,且最終陣列的型別會正確反映「解包」後的結果。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解法 / 最佳實踐 |
|---|---|---|
| 忘記遞迴解包 | 手寫 UnwrapPromise<T> 只支援單層,導致 Promise<Promise<T>> 仍留下 Promise。 |
直接使用 Awaited<T>,或自行實作遞迴版 type DeepAwaited<T> = T extends Promise<infer R> ? DeepAwaited<R> : T; |
與 any/unknown 混用 |
Awaited<any> 會仍是 any,失去型別保護。 |
在需要嚴格型別的地方,盡量避免 any,改用 unknown 再配合 Awaited<unknown> 進行顯式斷言。 |
誤用於非 Promise 的同步函式 |
把 Awaited<T> 用在純同步函式的回傳型別上,會產生不必要的複雜度。 |
僅在非同步或可能返回 thenable的情境使用 Awaited<T>。 |
忽略 null/undefined |
`Awaited<Promise |
null>會變成string |
| 過度使用導致可讀性下降 | 在簡單情況下直接寫 Promise<string> 會比 Awaited<Promise<string>> 更直觀。 |
只在需要「抽取」結果型別的地方使用 Awaited,保持程式碼可讀性。 |
最佳實踐:
- 利用
Awaited<T>取代手寫infer:減少錯誤、提升維護性。 - 在公共 API(例如 SDK)返回型別時,使用
Promise<Awaited<T>>,讓使用者看到最終值的型別。 - 結合條件型別,如
type Result<T> = Awaited<T> extends Error ? Failure : Success<Awaited<T>>;,讓錯誤處理更型別安全。 - 配合
as const產生的字面量型別一起使用,確保推斷的結果不會被寬鬆化。
實際應用場景
1. 建立通用的 API 客戶端
在大型前端專案中,常見的做法是將所有 HTTP 請求封裝成 返回 Promise<T> 的函式。若要為每個端點自動產生型別,Awaited 能在 型別映射 階段直接取得最終的資料結構:
type Endpoint<T> = () => Promise<T>;
type ResolvedEndpoint<E> = Awaited<ReturnType<E>>;
// 假設有兩個端點
declare const getUser: Endpoint<{ id: number; name: string }>;
declare const getPosts: Endpoint<Post[]>;
type UserData = ResolvedEndpoint<typeof getUser>; // { id: number; name: string }
type PostsData = ResolvedEndpoint<typeof getPosts>; // Post[]
這樣的寫法讓 IDE 能即時提示正確的屬性名稱,減少手寫型別的工作。
2. 編寫高度可組合的 async 高階函式
像是 asyncReduce, asyncFlatMap 等函式,需要同時接受 同步 與 非同步 的回傳值。使用 Awaited 可以一次解決兩者的型別差異:
async function asyncReduce<T, U>(arr: T[], fn: (acc: U, cur: T) => Promise<U> | U, init: U): Promise<Awaited<U>> {
let acc = init;
for (const item of arr) {
acc = await fn(acc, item);
}
return acc as Awaited<U>;
}
3. 在測試框架中驗證非同步回傳
測試工具(如 Jest)常會用 expect(await fn()).resolves.toEqual(...)。若要寫 型別安全 的自訂 matcher,Awaited 能幫助取得 fn 的最終回傳型別:
declare function expectAsync<T>(promise: Promise<T>): {
resolves: {
toEqual(expected: Awaited<T>): void;
};
};
總結
Awaited<T>是 官方提供 的遞迴型別解包工具,能把任意Promise、Thenable,甚至null/undefined正確地轉換成最終值的型別。- 相較於手寫的
infer解包,它具備 遞迴、安全(保留null/undefined)以及 支援 Thenable 的特性。 - 在 泛型函式、API 客戶端、非同步高階函式 等場景中使用
Awaited<T>,可以讓型別推斷變得更精確、程式碼更簡潔,同時減少因手動寫infer而產生的錯誤。 - 使用時注意避免 過度抽象、與
any混用 以及 忽略null/undefined的情況,遵循「只在需要解包時使用」的原則,能讓程式碼保持可讀且安全。
掌握 Awaited<T> 後,你將能更自信地在 TypeScript 專案中處理各種非同步型別,寫出 型別安全、可維護 的程式碼。祝你在 TypeScript 的非同步世界裡玩得開心! 🚀