本文 AI 產出,尚未審核

TypeScript – 錯誤處理與例外:自訂 Error 類別


簡介

在大型的 TypeScript 專案中,錯誤處理是確保系統穩定與可維護的關鍵。
JavaScript 原生只提供 ErrorTypeErrorRangeError 等少數例外類型,直接拋出這些錯誤往往只能得到「訊息」與「堆疊」,缺乏足夠的上下文資訊,讓後續的錯誤分類、日誌紀錄或使用者回饋變得困難。

透過 自訂 Error 類別,我們可以:

  1. 為不同的錯誤情境定義專屬的類別與錯誤代碼。
  2. 在例外物件中攜帶額外的屬性(例如 HTTP 狀態碼、錯誤來源、錯誤資料),讓錯誤處理程式能更精準地回應。
  3. 在 TypeScript 的型別系統下,利用 型別推斷型別保護(type guard)提升開發體驗與 IDE 補完。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,最後帶出實務應用場景,幫助你在 TypeScript 專案中建立可讀、可測、可維護的錯誤機制。


核心概念

1. 為什麼要繼承 Error

Error 本身已具備 namemessagestack 三個屬性,且在拋出 (throw) 時會自動產生堆疊追蹤。
繼承 Error 可以保留這些標準行為,同時加入自訂屬性,例如:

class ValidationError extends Error {
  constructor(public readonly field: string, message: string) {
    super(message);               // 設定錯誤訊息
    this.name = 'ValidationError';// 設定錯誤名稱
    // 修正 prototype 鏈(在某些環境下必要)
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

重點:在 TypeScript 中,Object.setPrototypeOf 必須在建構子裡呼叫,否則 instanceof 可能失效(尤其在 Babel、ES5 編譯目標時)。

2. 加入錯誤代碼(Error Code)

錯誤代碼是將錯誤類別與外部系統(如 API、資料庫)對應的橋樑。常見做法是使用 enum字串聯合類型

enum AppErrorCode {
  INVALID_INPUT = 'INVALID_INPUT',
  NOT_FOUND = 'NOT_FOUND',
  PERMISSION_DENIED = 'PERMISSION_DENIED',
}

class AppError extends Error {
  constructor(
    public readonly code: AppErrorCode,
    message: string,
    public readonly details?: unknown,
  ) {
    super(message);
    this.name = 'AppError';
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

3. 型別保護(Type Guard)

自訂錯誤類別後,我們常在 catch 區塊裡檢查錯誤型別。利用 TypeScript 的型別保護,可讓 IDE 正確推斷屬性。

function isAppError(err: unknown): err is AppError {
  return err instanceof AppError;
}

4. 與 async/await 結合

在非同步流程中拋出自訂錯誤,仍然可以使用 try / catch 捕捉,並依錯誤類別做不同處理。

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    // 依照 HTTP 狀態碼拋出不同的自訂錯誤
    if (response.status === 404) {
      throw new AppError(AppErrorCode.NOT_FOUND, `User ${id} not found`);
    }
    throw new AppError(AppErrorCode.INVALID_INPUT, `Invalid request`);
  }
  return response.json();
}

5. 統一錯誤工廠(Error Factory)

在大型專案中,錯誤的建立往往散布於各個模組。使用工廠函式可以集中管理錯誤訊息與代碼,降低重複與錯誤。

class ErrorFactory {
  static validation(field: string, message: string) {
    return new ValidationError(field, message);
  }

  static notFound(resource: string, identifier: string) {
    return new AppError(
      AppErrorCode.NOT_FOUND,
      `${resource} (${identifier}) not found`,
      { resource, identifier },
    );
  }
}

程式碼範例

以下提供 5 個實用範例,說明如何在不同情境下建立與使用自訂 Error。

範例 1:基本的 ValidationError

class ValidationError extends Error {
  constructor(public readonly field: string, message: string) {
    super(message);
    this.name = 'ValidationError';
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

// 使用
function validateAge(age: number) {
  if (age < 0 || age > 120) {
    throw new ValidationError('age', `年齡 ${age} 超出合理範圍`);
  }
}

說明:此錯誤僅攜帶「欄位名稱」與「訊息」,適合表單驗證或簡單的參數檢查。


範例 2:帶錯誤代碼的 AppError

enum AppErrorCode {
  INVALID_INPUT = 'INVALID_INPUT',
  NOT_FOUND = 'NOT_FOUND',
  PERMISSION_DENIED = 'PERMISSION_DENIED',
}

class AppError extends Error {
  constructor(
    public readonly code: AppErrorCode,
    message: string,
    public readonly details?: unknown,
  ) {
    super(message);
    this.name = 'AppError';
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

// 使用
function getConfig(key: string) {
  const config = { host: 'localhost' };
  if (!(key in config)) {
    throw new AppError(
      AppErrorCode.NOT_FOUND,
      `設定項目 ${key} 不存在`,
      { missingKey: key },
    );
  }
  return config[key as keyof typeof config];
}

說明details 欄位可放任意資料,利於日誌或錯誤回報系統。


範例 3:型別保護與錯誤分類

function isValidationError(err: unknown): err is ValidationError {
  return err instanceof ValidationError;
}

try {
  validateAge(-5);
} catch (e) {
  if (isValidationError(e)) {
    console.warn(`欄位 ${e.field} 驗證失敗:${e.message}`);
  } else {
    console.error('未知錯誤', e);
  }
}

說明:使用 instanceof 直接判斷也能工作,但透過型別保護函式可在其他條件判斷中保持型別資訊。


範例 4:非同步流程中的自訂錯誤

async function loadProduct(id: string): Promise<Product> {
  const res = await fetch(`/api/products/${id}`);
  if (!res.ok) {
    if (res.status === 404) {
      throw new AppError(AppErrorCode.NOT_FOUND, `商品 ${id} 不存在`);
    }
    throw new AppError(AppErrorCode.INVALID_INPUT, `取得商品失敗`);
  }
  return res.json();
}

// 呼叫端
(async () => {
  try {
    const product = await loadProduct('abc123');
    console.log(product);
  } catch (err) {
    if (err instanceof AppError) {
      // 根據錯誤代碼回傳不同的 HTTP 狀態或 UI 提示
      console.error(`[${err.code}] ${err.message}`);
    } else {
      console.error('未預期的錯誤', err);
    }
  }
})();

說明:在 catch 中直接使用 instanceof AppError 即可取得 codedetails 等自訂屬性。


範例 5:錯誤工廠(ErrorFactory)與全域錯誤處理

class ErrorFactory {
  static validation(field: string, msg: string) {
    return new ValidationError(field, msg);
  }

  static notFound(resource: string, id: string) {
    return new AppError(
      AppErrorCode.NOT_FOUND,
      `${resource} (${id}) 找不到`,
      { resource, id },
    );
  }
}

// 全域錯誤處理器(例如在 Express 中)
import express from 'express';
const app = express();

app.use((err: unknown, _req, res, _next) => {
  if (err instanceof ValidationError) {
    res.status(400).json({ error: err.message, field: err.field });
  } else if (err instanceof AppError) {
    const status = err.code === AppErrorCode.NOT_FOUND ? 404 : 400;
    res.status(status).json({ code: err.code, message: err.message, details: err.details });
  } else {
    console.error('未捕捉的例外', err);
    res.status(500).json({ message: '系統內部錯誤' });
  }
});

// 範例路由
app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await loadUser(req.params.id);
    if (!user) throw ErrorFactory.notFound('User', req.params.id);
    res.json(user);
  } catch (e) {
    next(e); // 交給全域錯誤處理器
  }
});

說明:使用工廠集中產生錯誤,讓路由或服務層的程式碼保持乾淨;全域錯誤處理器則根據錯誤類別回傳適切的 HTTP 狀態與訊息。


常見陷阱與最佳實踐

陷阱 為何會發生 解決方式
instanceof 判斷失效 產生錯誤的檔案與判斷檔案使用不同的模組系統(CommonJS vs ESModules)或編譯目標為 ES5 時,prototype 可能被破壞。 在建構子最後加入 Object.setPrototypeOf(this, new.target.prototype);,或使用 type guard
錯誤訊息遺失 直接 throw new Error(message) 後,若在 catch 中重新拋出 (throw err) 可能會失去原始 stack。 使用 throw err; 前不做任何修改;若需包裝,保留 originalError.stack
過度自訂屬性 把太多資料塞進錯誤物件,會讓錯誤變得沉重且難以序列化。 僅保留必要的 codedetails,其他資料可放在 details 內的結構化物件。
忘記 name 屬性 堆疊追蹤只會顯示 Error,不利於辨識錯誤類型。 在建構子內明確設定 this.name = 'YourErrorName'
未統一錯誤格式 各模組自行拋出不同結構的錯誤,導致全域處理器寫起來很雜。 建立 ErrorFactoryBaseError 抽象類別,所有自訂錯誤皆繼承它。

最佳實踐

  1. 建立 BaseError:把 codedetailsname 的共通邏輯集中在一個抽象類別。
  2. 使用 Enum 管理錯誤代碼:避免硬編碼字串,利於搜尋與重構。
  3. catch 中盡早分類:先檢查 instanceof 或 type guard,再決定是否重新拋出或回傳錯誤資訊。
  4. 保留堆疊資訊:自訂錯誤仍應保留 stack,方便除錯與日誌。
  5. 避免在建構子裡做非同步工作:建構子應保持同步,任何非同步驗證或 I/O 應在外層完成後再拋錯。

實際應用場景

1. 表單驗證與前端 UI 提示

在 React 或 Vue 的表單元件中,驗證失敗時拋出 ValidationError,捕獲後直接映射到表單欄位的錯誤訊息。

try {
  await api.updateProfile(data);
} catch (e) {
  if (e instanceof ValidationError) {
    setFieldError(e.field, e.message); // UI 顯示
  } else {
    toast.error('更新失敗,請稍後再試');
  }
}

2. RESTful API 的錯誤回傳

後端使用 Express 時,統一把 AppError 轉成符合 OpenAPI 規範的 JSON 回應,前端只需要根據 code 做相應的 UI 處理。

{
  "code": "NOT_FOUND",
  "message": "User (123) not found",
  "details": { "resource": "User", "id": "123" }
}

3. 微服務間的錯誤傳遞

在微服務架構中,一個服務拋出 AppError,透過 gRPC 或訊息佇列傳遞錯誤代碼與訊息,接收端可根據代碼決定是否重試、回退或直接回報使用者。

4. 任務排程與批次處理

批次作業常需要記錄失敗原因,使用 details 把失敗的資料列印至日誌或寫入錯誤報表,之後可自動產生修復腳本。

catch (err) {
  if (err instanceof AppError && err.code === AppErrorCode.INVALID_INPUT) {
    await errorReport.save({ jobId, ...err.details });
  }
}

總結

自訂 Error 類別是 TypeScript 錯誤處理 的基礎功,透過繼承 Error、加入錯誤代碼與結構化資訊,我們可以:

  • 提升除錯效率:完整的堆疊與型別資訊讓 IDE 與除錯工具更有用。
  • 統一錯誤介面:全域錯誤處理器只需要關注 AppErrorValidationError 等少數類型。
  • 加強業務語意:錯誤代碼與 details 讓開發者與非技術人員都能快速了解問題根源。

在實作時,記得 設定 name、修正 prototype使用 Enum 管理代碼,以及 建立錯誤工廠 以保持程式碼的可維護性。把這套錯誤機制融入你的服務、前端與測試流程,將為整個系統的穩定性與可觀測性奠定堅實基礎。祝你在 TypeScript 的旅程中,寫出更安全、更易除錯的程式碼!