TypeScript - 異步與 Promise 型別(Async & Promise)
主題:回傳型別自動推斷
簡介
在現代前端開發中,非同步程式已成為日常。無論是呼叫 API、讀寫檔案、或是與 Web Worker 互動,都離不開 Promise。
TypeScript 透過 型別推斷(type inference),讓開發者不必手動寫出繁瑣的回傳型別,編譯器會自動根據程式碼內容推算正確的型別。這不僅減少冗長的程式碼,也能在編譯階段即時捕捉錯誤,提高開發效率與程式的可維護性。
本篇文章將深入探討 Promise 回傳型別的自動推斷,說明它的運作原理、常見陷阱與最佳實踐,並提供多個實務範例,幫助你在日常開發中正確、有效地運用 TypeScript 的型別推斷機制。
核心概念
1. 基本的 Promise 型別推斷
當你寫一個回傳 Promise 的函式,若沒有顯式指定型別,TypeScript 會根據 resolve 或 reject 的值自動推斷。
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 並自行縮小。 |
多分支返回 null 或 undefined |
產生聯合型別,若未做檢查會產生「可能為 null」的錯誤。 | 在使用前進行 null/undefined 檢查,或使用 non-null assertion(!)僅在確定不會為 null 時使用。 |
Promise.resolve 的型別推斷 |
若傳入的值是 Promise 本身,會被「扁平化」成內層型別,可能造成誤判。 |
了解扁平化行為,必要時使用 Promise<Promise<T>> 明確包裝。 |
async 函式內的 throw 未被捕獲 |
錯誤會變成 rejected 的 Promise,若未 await 或 .catch 會導致未處理的拒絕。 |
在呼叫端使用 try…catch,或在函式內部自行捕獲並回傳 Result 物件。 |
最佳實踐
- 讓編譯器自行推斷:除非有特殊需求,盡量不寫明確的
Promise<T>,讓 TypeScript 從resolve/return推斷。 - 使用泛型封裝共通工具:如
delay<T>、fetchJson<T>,可保留完整型別資訊,避免any。 - 型別縮小:在處理聯合型別時,使用
typeof、instanceof、或自訂型別守衛(type guard)縮小型別。 - 善用
Awaited與ReturnType:在大型程式碼基底中,這兩個工具型別能幫助你抽取已存在函式的回傳型別,保持一致性。 - 保持錯誤處理的一致性:所有
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 的非同步程式設計中,寫出 安全、易維護且具備完整型別資訊 的程式碼。祝你開發順利,玩得開心! 🚀