本文 AI 產出,尚未審核

TypeScript – 異步與 Promise 型別

主題:Awaited<T> 工具型別


簡介

TypeScript 中處理非同步程式碼時,Promiseasync/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 為止
nullundefined 保持原樣
其他非 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

說明nullundefined 不會被「吞掉」,保持原樣,避免意外的型別收斂。


範例 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,保持程式碼可讀性。

最佳實踐

  1. 利用 Awaited<T> 取代手寫 infer:減少錯誤、提升維護性。
  2. 在公共 API(例如 SDK)返回型別時,使用 Promise<Awaited<T>>,讓使用者看到最終值的型別。
  3. 結合條件型別,如 type Result<T> = Awaited<T> extends Error ? Failure : Success<Awaited<T>>;,讓錯誤處理更型別安全。
  4. 配合 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 的非同步世界裡玩得開心! 🚀