本文 AI 產出,尚未審核

TypeScript 異步與 Promise:Error 型別推論


簡介

在 JavaScript 裡,throw 與 try / catch 是處理非同步錯誤的核心機制。當我們把程式碼搬到 TypeScript 時,除了要考慮執行時的例外外,還必須思考 型別:

  • catch 區塊接收到的值到底是什麼型別?
  • 我們如何在不失去型別安全的前提下,取得錯誤的詳細資訊?

如果忽視這些問題,最常見的結果就是 any 或 unknown 的濫用,讓編譯器失去保護力,甚至在執行階段產生難以偵測的錯誤。本文將從 Error 型別推論 的角度,說明在 Async / Promise 流程中,如何正確、有效地取得錯誤資訊,並以實務範例展示最佳寫法。


核心概念

1. 為什麼要推論 Error 型別

在 TypeScript 4.0 之後,catch 區塊的變數預設型別從 any 變成 unknown,這是為了強迫開發者自行斷言或檢查。unknown 雖然安全,但如果不加以處理,就會讓錯誤訊息「看不見」:

try {
  await fetchData();
} catch (e) {
  // e 為 unknown,直接使用 e.message 會編譯錯誤
  console.error(e.message); // ❌ TypeScript error
}

因此,我們需要 將 unknown 轉換成更具體的型別(如 Error、自訂錯誤類別),才能安全地存取 message、stack 或自訂屬性。

2. 基本的 Promise 錯誤處理

以下示範最簡單的 async / await + try / catch 寫法,並使用 型別斷言 讓編譯器知道錯誤是 Error:

async function getUser(id: number): Promise<User> {
  const resp = await fetch(`/api/users/${id}`);
  if (!resp.ok) throw new Error(`User ${id} not found`);
  return resp.json();
}

async function showUser(id: number) {
  try {
    const user = await getUser(id);
    console.log('User:', user);
  } catch (e) {
    // 斷言 e 為 Error
    const err = e as Error;
    console.error('❗️ 取得使用者失敗:', err.message);
  }
}

要點:

  • 使用 as Error 直接斷言是最直接的方式,但若錯誤來源不一定是 Error(例如拋出字串),會失去安全性。

3. unknown vs any:安全的錯誤型別檢查

為了避免過度斷言,我們可以寫一個 type guard,將 unknown 轉為 Error:

function isError(value: unknown): value is Error {
  return value instanceof Error;
}

async function updateProfile(data: Profile) {
  try {
    await api.update(data);
  } catch (e) {
    if (isError(e)) {
      // 此時 e 已被縮小為 Error
      console.warn('API 錯誤:', e.message);
    } else {
      // 不是 Error,可能是字串或自訂物件
      console.warn('未知錯誤類型:', e);
    }
  }
}

重點:

  • instanceof Error 能辨識原生的 Error 以及繼承自 Error 的自訂類別。
  • 當 e 不是 Error 時,我們仍保留 unknown,避免隱藏潛在問題。

4. 自訂 Error 類別與型別推論

在大型專案中,常會自訂錯誤類別(例如 HttpError、ValidationError),此時 type guard 需要更精細:

class HttpError extends Error {
  constructor(public status: number, message: string) {
    super(message);
    this.name = 'HttpError';
  }
}

function isHttpError(value: unknown): value is HttpError {
  return value instanceof HttpError;
}

async function fetchWithAuth(url: string) {
  try {
    const resp = await fetch(url);
    if (!resp.ok) throw new HttpError(resp.status, 'Request failed');
    return await resp.json();
  } catch (e) {
    if (isHttpError(e)) {
      // 可以直接取得 status
      console.error(`HTTP ${e.status}: ${e.message}`);
    } else {
      console.error('非 HTTP 錯誤:', e);
    }
  }
}

這樣的寫法讓 IDE 能正確提示 e.status,同時保留型別安全。

5. Async 函式的返回型別與錯誤推論

async 函式永遠回傳 Promise<T>,即使在 catch 內部重新拋出錯誤,外部仍會得到 Promise<T>,錯誤會被 Reject。若我們想在函式簽名中表達「可能會拋出 HttpError」的資訊,雖然 TypeScript 本身沒有內建的 Promise<…, E>,但可以透過 泛型 搭配 Result 型別自行描述:

type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

async function safeFetch(url: string): Promise<Result<any, HttpError>> {
  try {
    const resp = await fetch(url);
    if (!resp.ok) throw new HttpError(resp.status, 'Network error');
    const data = await resp.json();
    return { ok: true, value: data };
  } catch (e) {
    if (isHttpError(e)) return { ok: false, error: e };
    // 其他錯誤包成 generic Error
    return { ok: false, error: e as Error };
  }
}

// 使用端
(async () => {
  const result = await safeFetch('/api/items');
  if (result.ok) {
    console.log('資料:', result.value);
  } else {
    console.error('取得失敗:', result.error.message);
  }
})();

透過 Result 型別,我們在 呼叫端 明確知道錯誤的型別,而不必在 catch 中再次做型別判斷。


常見陷阱與最佳實踐

陷阱 說明 解決方案
直接拋出字串或物件 throw "Oops"、throw { code: 123 } 會使 catch 變成 unknown,失去 Error 的屬性。 只拋出 Error 或其子類別,必要時自行包裝資訊。
在 catch 中使用 any catch (e: any) 把安全檢查全部關閉,容易誤用。 使用預設的 unknown,搭配 type guard 逐層縮小。
忘記 await async 函式內忘記 await,錯誤會直接變成未處理的 Promise Rejection。 確保每個返回 Promise 的呼叫都有 await(或 .catch)。
在 finally 中重新拋錯 finally 內的錯誤會 覆寫 先前的錯誤,導致原始錯誤資訊遺失。 只在 finally 做清理工作,若需要拋錯,先保存原始錯誤。
忽略自訂錯誤的屬性 自訂錯誤類別有額外屬性(如 status),但在 catch 中只使用 e.message。 使用對應的 type guard,取得全部自訂屬性。

最佳實踐:

  1. 統一錯誤類別:在專案根目錄建立 errors.ts,集中管理所有自訂錯誤。
  2. 使用 type guard:每個自訂錯誤都提供 isXError 函式,讓 catch 內部保持乾淨。
  3. 避免 any:除非真的無法避免,否則堅持使用 unknown + 類型縮小。
  4. 錯誤資訊串接:在拋錯前加入上下文(如 API 路徑、參數),方便除錯。

實際應用場景

1. 前端 UI 錯誤訊息顯示

在 React + TypeScript 專案中,常見的需求是「根據錯誤類別顯示不同的 toast」:

import { isHttpError } from './errors';

async function submitForm(data: FormData) {
  try {
    await api.save(data);
    toast.success('儲存成功');
  } catch (e) {
    if (isHttpError(e) && e.status === 401) {
      toast.error('未授權,請重新登入');
    } else if (e instanceof Error) {
      toast.error(e.message);
    } else {
      toast.error('未知錯誤,請稍後再試');
    }
  }
}

透過 isHttpError,UI 能直接根據 HTTP 狀態碼 做客製化回饋。

2. Node.js 後端服務的統一錯誤處理

在 Express + TypeScript 中,我們可以把所有路由的錯誤轉成 Result,最後交給全域錯誤中介軟體:

// middleware/resultHandler.ts
import { Request, Response, NextFunction } from 'express';
import { Result } from './result';

export function resultHandler(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // 假設所有路由都回傳 Promise<Result>
  (req as any).result
    .then((result: Result<any, Error>) => {
      if (result.ok) {
        res.json(result.value);
      } else {
        // 統一回傳錯誤格式
        res.status(500).json({ error: result.error.message });
      }
    })
    .catch(next); // 未捕獲的錯誤交給 Express default handler
}

路由:

router.get('/items', async (req, res, next) => {
  (req as any).result = safeFetch('/api/items');
});

這樣的設計把 錯誤型別推論 前置於服務層,讓最終的錯誤回應保持一致。


總結

  • Error 型別推論 是保護 TypeScript 程式碼在非同步環境中不被隱藏例外的關鍵。
  • 從 unknown 開始,使用 type guard 把錯誤縮小為 Error、HttpError 或其他自訂類別,才能安全存取 message、stack、自訂屬性。
  • 自訂錯誤類別 搭配 instanceof 判斷,可讓 IDE 提供完整的屬性提示,提升開發效率。
  • 若想在函式簽名中表達錯誤型別,Result<T, E> 之類的泛型封裝是一個實用的方案,讓呼叫端在編譯期即得知可能的錯誤。
  • 避免常見陷阱(拋出非 Error、使用 any、忽略自訂屬性),並遵循 統一錯誤類別、type guard、錯誤資訊串接 的最佳實踐。

掌握了這套 Error 型別推論 的思維後,你的 TypeScript 非同步程式碼將更加可預測、易除錯、且具備良好的型別安全,在前端 UI、Node.js 後端甚至跨平台的全端專案中,都能發揮巨大的價值。祝你寫程式開心,錯誤處理更順手!