TypeScript – 錯誤處理與例外:自訂 Error 類別
簡介
在大型的 TypeScript 專案中,錯誤處理是確保系統穩定與可維護的關鍵。
JavaScript 原生只提供 Error、TypeError、RangeError 等少數例外類型,直接拋出這些錯誤往往只能得到「訊息」與「堆疊」,缺乏足夠的上下文資訊,讓後續的錯誤分類、日誌紀錄或使用者回饋變得困難。
透過 自訂 Error 類別,我們可以:
- 為不同的錯誤情境定義專屬的類別與錯誤代碼。
- 在例外物件中攜帶額外的屬性(例如 HTTP 狀態碼、錯誤來源、錯誤資料),讓錯誤處理程式能更精準地回應。
- 在 TypeScript 的型別系統下,利用 型別推斷 與 型別保護(type guard)提升開發體驗與 IDE 補完。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,最後帶出實務應用場景,幫助你在 TypeScript 專案中建立可讀、可測、可維護的錯誤機制。
核心概念
1. 為什麼要繼承 Error?
Error 本身已具備 name、message、stack 三個屬性,且在拋出 (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即可取得code、details等自訂屬性。
範例 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。 |
| 過度自訂屬性 | 把太多資料塞進錯誤物件,會讓錯誤變得沉重且難以序列化。 | 僅保留必要的 code、details,其他資料可放在 details 內的結構化物件。 |
忘記 name 屬性 |
堆疊追蹤只會顯示 Error,不利於辨識錯誤類型。 |
在建構子內明確設定 this.name = 'YourErrorName'。 |
| 未統一錯誤格式 | 各模組自行拋出不同結構的錯誤,導致全域處理器寫起來很雜。 | 建立 ErrorFactory 或 BaseError 抽象類別,所有自訂錯誤皆繼承它。 |
最佳實踐
- 建立 BaseError:把
code、details、name的共通邏輯集中在一個抽象類別。 - 使用 Enum 管理錯誤代碼:避免硬編碼字串,利於搜尋與重構。
- 在
catch中盡早分類:先檢查instanceof或 type guard,再決定是否重新拋出或回傳錯誤資訊。 - 保留堆疊資訊:自訂錯誤仍應保留
stack,方便除錯與日誌。 - 避免在建構子裡做非同步工作:建構子應保持同步,任何非同步驗證或 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 與除錯工具更有用。
- 統一錯誤介面:全域錯誤處理器只需要關注
AppError、ValidationError等少數類型。 - 加強業務語意:錯誤代碼與
details讓開發者與非技術人員都能快速了解問題根源。
在實作時,記得 設定 name、修正 prototype、使用 Enum 管理代碼,以及 建立錯誤工廠 以保持程式碼的可維護性。把這套錯誤機制融入你的服務、前端與測試流程,將為整個系統的穩定性與可觀測性奠定堅實基礎。祝你在 TypeScript 的旅程中,寫出更安全、更易除錯的程式碼!