本文 AI 產出,尚未審核

TypeScript - 異步與 Promise 型別(Async & Promise)

主題:回傳型別自動推斷


簡介

在現代前端開發中,非同步程式已成為日常。無論是呼叫 API、讀寫檔案、或是與 Web Worker 互動,都離不開 Promise
TypeScript 透過 型別推斷(type inference),讓開發者不必手動寫出繁瑣的回傳型別,編譯器會自動根據程式碼內容推算正確的型別。這不僅減少冗長的程式碼,也能在編譯階段即時捕捉錯誤,提高開發效率與程式的可維護性。

本篇文章將深入探討 Promise 回傳型別的自動推斷,說明它的運作原理、常見陷阱與最佳實踐,並提供多個實務範例,幫助你在日常開發中正確、有效地運用 TypeScript 的型別推斷機制。


核心概念

1. 基本的 Promise 型別推斷

當你寫一個回傳 Promise 的函式,若沒有顯式指定型別,TypeScript 會根據 resolvereject 的值自動推斷。

function fetchNumber(): Promise<number> {
  return new Promise((resolve) => {
    setTimeout(() => resolve(42), 1000);
  });
}

上例中,resolve(42) 的參數是 number,因此編譯器推斷出 Promise<number>。即使我們省略了 : Promise<number>,結果仍相同:

function fetchNumber() {
  return new Promise((resolve) => {
    setTimeout(() => resolve(42), 1000);
  });
}

// 使用時,TypeScript 仍會將返回值視為 Promise<number>
const result = await fetchNumber(); // result: number

重點:只要 resolve 的值是唯一且明確的型別,推斷就會正確。

2. 多分支的 Promise 推斷

resolve 可能傳回不同型別時,TypeScript 會使用 聯合型別(union type) 來推斷。

function fetchData(flag: boolean) {
  return new Promise((resolve) => {
    if (flag) {
      resolve({ id: 1, name: "Alice" });   // object
    } else {
      resolve("No data");                 // string
    }
  });
}

// 推斷結果:Promise<{ id: number; name: string } | string>
const data = await fetchData(true);
// data 的型別為 { id: number; name: string } | string

在使用 await.then() 時,必須自行縮小型別(type narrowing),否則編譯器會警告無法直接存取特定屬性。

if (typeof data === "string") {
  console.log(data.toUpperCase());
} else {
  console.log(data.name); // data 為 object 時安全
}

3. 使用 async 函式的自動推斷

async 函式會自動把回傳值包裝成 Promise,且推斷的型別是 返回值的型別

async function getUser(id: number) {
  const response = await fetch(`/api/user/${id}`);
  const user = await response.json(); // 假設回傳 { id: number; name: string }
  return user; // 推斷為 Promise<{ id: number; name: string }>
}

如果在函式內部拋出錯誤,async 函式的回傳型別仍是 Promise<T>,錯誤會以 rejected 的形式傳遞,不影響推斷結果。

4. 泛型與 Promise 推斷的結合

有時候我們會寫一個 通用的非同步工具函式,此時可以搭配泛型讓推斷更精確。

function delay<T>(ms: number, value: T): Promise<T> {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

// 呼叫時,T 會根據傳入的 value 推斷
const numPromise = delay(500, 123); // Promise<number>
const strPromise = delay(300, "hello"); // Promise<string>

即使不寫 <T>,TypeScript 仍會根據 value 推斷:

function delay(ms: number, value: any) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

// 推斷結果為 Promise<any>,失去型別安全

使用泛型可以保留完整的型別資訊,避免 any 帶來的隱藏錯誤。

5. 透過 ReturnType 取得函式的 Promise 型別

在大型專案中,我們常常需要 取得已有函式的回傳型別,例如作為其他函式的參數型別。ReturnType<T> 搭配 Awaited<T>(TS 4.5)可直接取得 Promise 包裹的型別。

async function fetchPosts() {
  const res = await fetch("/api/posts");
  return res.json(); // 推斷為 Promise<any[]>(此例未指定)
}

// 取得 fetchPosts 回傳的元素型別
type Posts = Awaited<ReturnType<typeof fetchPosts>>; // any[]

fetchPosts 已正確指定回傳型別,Posts 也會自動推斷:

async function fetchPosts(): Promise<Post[]> {
  const res = await fetch("/api/posts");
  return res.json();
}
type Posts = Awaited<ReturnType<typeof fetchPosts>>; // Post[]

程式碼範例

範例 1️⃣:最簡單的自動推斷

function getCurrentTime() {
  return new Promise((resolve) => {
    resolve(new Date()); // Date 型別
  });
}

// 使用
(async () => {
  const now = await getCurrentTime(); // now: Date
  console.log(now.toISOString());
})();

說明resolve(new Date()) 讓編譯器推斷 Promise<Date>,呼叫端直接得到 Date 物件。


範例 2️⃣:多分支回傳 + 型別縮小

function findUser(id: number) {
  return new Promise((resolve) => {
    // 假設從資料庫查詢
    const user = mockDB.find((u) => u.id === id);
    if (user) {
      resolve(user); // { id: number; name: string }
    } else {
      resolve(null); // null
    }
  });
}

// 使用
(async () => {
  const result = await findUser(3);
  // result 型別為 { id: number; name: string } | null
  if (result) {
    console.log(`Found: ${result.name}`);
  } else {
    console.log("User not found");
  }
})();

重點:使用 if (result) 進行 null 檢查,讓 TypeScript 知道此時 result 為物件型別。


範例 3️⃣:async 函式結合泛型

async function fetchJson<T>(url: string): Promise<T> {
  const response = await fetch(url);
  const data = (await response.json()) as T;
  return data;
}

// 呼叫
interface Article {
  id: number;
  title: string;
  content: string;
}

(async () => {
  const article = await fetchJson<Article>("/api/article/10");
  // article 型別正確推斷為 Article
  console.log(article.title);
})();

說明:透過 <T> 泛型,我們在呼叫時指定回傳型別,fetchJson 內部不需再寫任何型別斷言。


範例 4️⃣:delay 工具函式的推斷

function delay<T>(ms: number, value: T): Promise<T> {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

// 範例使用
const p1 = delay(1000, 42);          // Promise<number>
const p2 = delay(500, { ok: true }); // Promise<{ ok: boolean }>

(async () => {
  const n = await p1; // n: number
  const obj = await p2; // obj: { ok: boolean }
  console.log(n, obj);
})();

技巧delay 讓你在測試或 UI 動畫時,仍能保留完整的型別資訊。


範例 5️⃣:使用 ReturnType + Awaited 取得內部型別

async function loadConfig(): Promise<Config> {
  const res = await fetch("/config.json");
  return res.json(); // Config
}

// 取得 Config 型別
type Config = Awaited<ReturnType<typeof loadConfig>>;

// 在別處使用
function printConfig(cfg: Config) {
  console.log(`API endpoint: ${cfg.apiUrl}`);
}

說明Awaited<ReturnType<...>>Promise<Config> 轉成 Config,讓其他函式直接使用正確型別。


常見陷阱與最佳實踐

陷阱 可能產生的問題 解決方案
忘記 await 返回的是 Promise 而非實際值,導致型別為 Promise<T>,使用時須手動 .then()await 始終在非同步上下文中使用 await,或在函式簽名上明確標註 Promise<T>
回傳 any 型別推斷失效,編譯器無法提供安全檢查。 盡量避免 any,使用泛型或明確型別,必要時使用 unknown 並自行縮小。
多分支返回 nullundefined 產生聯合型別,若未做檢查會產生「可能為 null」的錯誤。 在使用前進行 null/undefined 檢查,或使用 non-null assertion!)僅在確定不會為 null 時使用。
Promise.resolve 的型別推斷 若傳入的值是 Promise 本身,會被「扁平化」成內層型別,可能造成誤判。 了解扁平化行為,必要時使用 Promise<Promise<T>> 明確包裝。
async 函式內的 throw 未被捕獲 錯誤會變成 rejected 的 Promise,若未 await.catch 會導致未處理的拒絕。 在呼叫端使用 try…catch,或在函式內部自行捕獲並回傳 Result 物件。

最佳實踐

  1. 讓編譯器自行推斷:除非有特殊需求,盡量不寫明確的 Promise<T>,讓 TypeScript 從 resolve/return 推斷。
  2. 使用泛型封裝共通工具:如 delay<T>fetchJson<T>,可保留完整型別資訊,避免 any
  3. 型別縮小:在處理聯合型別時,使用 typeofinstanceof、或自訂型別守衛(type guard)縮小型別。
  4. 善用 AwaitedReturnType:在大型程式碼基底中,這兩個工具型別能幫助你抽取已存在函式的回傳型別,保持一致性。
  5. 保持錯誤處理的一致性:所有 async 呼叫都應該有明確的錯誤捕獲策略(try…catch.catch、或 Result 包裝),以免未處理的 Promise 拒絕造成程式崩潰。

實際應用場景

1. 前端 UI 的 Loading 狀態管理

在 React、Vue 或 Svelte 中,常會寫一個 通用的資料取得 Hook,回傳 Promise<T>。透過型別推斷,開發者可以直接在組件內取得正確的資料型別,而不必重覆寫型別。

// useFetch.ts
import { useState, useEffect } from "react";

export function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    (async () => {
      try {
        const res = await fetch(url);
        const json = (await res.json()) as T;
        setData(json);
      } catch (e) {
        setError(e as Error);
      } finally {
        setLoading(false);
      }
    })();
  }, [url]);

  return { data, loading, error };
}

使用時:

interface User {
  id: number;
  name: string;
}
const { data: user } = useFetch<User>("/api/user/1");
// user 型別自動推斷為 User | null

2. 中間層服務的 API 客戶端

在 Node.js 後端或 Serverless 函式中,常會寫 API 客戶端套件,每個方法回傳 Promise<T>。透過泛型與自動推斷,開發者只要寫一次型別定義,即可在所有呼叫點取得正確型別。

class ApiClient {
  async get<T>(path: string): Promise<T> {
    const res = await fetch(`https://api.example.com${path}`);
    return (await res.json()) as T;
  }
}

// 呼叫
interface Product {
  id: number;
  price: number;
}
const client = new ApiClient();
const product = await client.get<Product>("/products/5");
// product 型別正確為 Product

3. 測試環境的模擬非同步函式

在單元測試中,我們常需要 模擬延遲或非同步回傳。利用 delay<T> 可以保留型別,讓測試程式碼保持嚴謹。

test("should wait for async value", async () => {
  const value = await delay<number>(50, 123);
  expect(value).toBe(123); // value 被推斷為 number
});

總結

  • Promise 回傳型別的自動推斷 讓我們可以寫出更簡潔、可讀性更高的程式碼,同時仍然享有 TypeScript 強大的型別安全。
  • 只要 resolve(或 async 函式的 return)提供足夠的資訊,編譯器就能推斷出正確的 Promise<T>;若有多種可能,會產生聯合型別,需要自行縮小。
  • 泛型Awaited + ReturnType、以及 型別守衛 是提升推斷精準度與可維護性的關鍵工具。
  • 在實務開發中,從 UI loading 管理、API 客戶端,到測試環境,都能透過型別推斷減少手動寫型別的負擔,降低錯誤率。

掌握了這些概念後,你就能在 TypeScript 的非同步程式設計中,寫出 安全、易維護且具備完整型別資訊 的程式碼。祝你開發順利,玩得開心! 🚀