本文 AI 產出,尚未審核

JavaScript 教學:錯誤與除錯 ── 自訂錯誤類型(Custom Error)


簡介

在日常開發中,我們往往會遇到各式各樣的錯誤:語法錯誤、執行時例外、業務邏輯不符合規則…如果只能依賴原生的 Error 物件,往往難以在程式碼中快速定位問題根源,也不利於向呼叫端傳遞清楚、結構化的錯誤資訊。

自訂錯誤類型(Custom Error)正是為了解決這個痛點而生。透過繼承 Error,我們可以為不同的錯誤情境建立專屬的類別,讓錯誤訊息更具語意、堆疊資訊更清楚,並且在捕獲(catch)時能以 instanceof 判斷錯誤類別,實作更精細的錯誤處理流程。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,一直到實務應用場景,帶你一步步掌握 自訂錯誤類型 的寫法與使用方式,適合剛接觸 JavaScript 的新手,也能為中級開發者提供更完整的除錯技巧。


核心概念

1. 為什麼要自訂錯誤?

原生 Error 自訂錯誤
訊息只能是單一字串 可以加入自訂屬性(如 code, status, payload
無法直接辨識錯誤來源 透過類別名稱即可判斷錯誤類型
捕獲時只能靠字串比對 可使用 instanceoferror.constructor.name

重點:自訂錯誤讓錯誤資訊更具結構,也讓錯誤處理程式碼更具可讀性與可維護性。

2. 繼承 Error 的基本寫法

在 ES6 以前,我們需要手動設定 prototypenamestack 等屬性;而在 ES6+(包括 TypeScript)中,只要 extends Error,大部分工作交給 JavaScript 引擎處理即可。

class MyError extends Error {
  constructor(message) {
    super(message);               // 呼叫 Error 的建構子,設定訊息與 stack
    this.name = this.constructor.name; // 設定錯誤名稱為類別名稱
    // 若要在瀏覽器或 Node.js 中正確捕獲 stack,可額外寫以下兩行
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

小技巧this.name = this.constructor.name 讓錯誤名稱自動與類別保持同步,避免手動寫錯。

3. 為錯誤加入自訂屬性

自訂錯誤最常見的需求是把錯誤代碼(error code)或 HTTP 狀態碼(status code)一起帶出,讓呼叫端能依據代碼做不同的回應。

class ValidationError extends Error {
  /**
   * @param {string} message 錯誤訊息
   * @param {string[]} fields 失敗驗證的欄位名稱
   */
  constructor(message, fields) {
    super(message);
    this.name = this.constructor.name;
    this.fields = fields;          // 失敗的欄位清單
    this.code = 'VALIDATION_ERR';  // 自訂錯誤代碼
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

4. 使用 instanceof 判斷錯誤類型

try {
  // 假設下面的函式會拋出 ValidationError
  validateUserInput(data);
} catch (err) {
  if (err instanceof ValidationError) {
    console.warn('驗證失敗:', err.fields);
    // 針對驗證錯誤回傳 400 Bad Request
  } else if (err instanceof MyError) {
    console.error('自訂錯誤:', err.message);
  } else {
    console.error('未知錯誤:', err);
  }
}

提醒instanceof 只能在同一執行環境(同一個 realm)下正確判斷;跨窗口或跨 Node.js worker 時,建議使用 err.name === 'ValidationError'

5. 多層級的錯誤類別結構

對大型專案而言,將錯誤分類成「系統錯誤 → 資料庫錯誤 → 業務錯誤」等層級,可讓錯誤處理更有彈性。

class AppError extends Error {
  constructor(message, options = {}) {
    super(message);
    this.name = this.constructor.name;
    this.code = options.code || 'APP_ERR';
    this.status = options.status || 500; // HTTP 狀態碼
    this.payload = options.payload;      // 任意附帶資料
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

class DatabaseError extends AppError {
  constructor(message, query) {
    super(message, { code: 'DB_ERR', status: 500 });
    this.query = query; // 失敗的 SQL / NoSQL 查詢
  }
}

class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, { code: 'NOT_FOUND', status: 404 });
    this.resource = resource;
  }
}

程式碼範例

下面提供 5 個實用範例,從最簡單的自訂錯誤到結合 HTTP API 的完整流程,說明每一步的意圖與注意事項。

範例 1:最簡單的自訂錯誤

class SimpleError extends Error {
  constructor(message) {
    super(message);
    this.name = 'SimpleError';
  }
}

// 使用
function doSomething(flag) {
  if (!flag) throw new SimpleError('flag 必須為 true');
}

try {
  doSomething(false);
} catch (e) {
  console.log(e.name);    // SimpleError
  console.log(e.message); // flag 必須為 true
}

說明:只改寫 name,即可在捕獲時快速辨識錯誤類型。


範例 2:帶有錯誤代碼的驗證錯誤

class ValidationError extends Error {
  constructor(message, fields) {
    super(message);
    this.name = 'ValidationError';
    this.fields = fields;               // 失敗的欄位
    this.code = 'VALIDATION_ERROR';
  }
}

// 檢查使用者輸入
function validate(data) {
  const errors = [];
  if (!data.email.includes('@')) errors.push('email');
  if (data.age < 0) errors.push('age');

  if (errors.length) {
    throw new ValidationError('資料驗證失敗', errors);
  }
}

// 捕獲示範
try {
  validate({ email: 'wrongemail', age: -5 });
} catch (err) {
  if (err instanceof ValidationError) {
    console.warn(`錯誤代碼:${err.code}`, err.fields);
    // => 錯誤代碼:VALIDATION_ERROR [ 'email', 'age' ]
  }
}

範例 3:結合 HTTP 狀態碼的應用錯誤

class HttpError extends Error {
  /**
   * @param {number} status HTTP 狀態碼
   * @param {string} message 錯誤訊息
   */
  constructor(status, message) {
    super(message);
    this.name = 'HttpError';
    this.status = status;
    this.code = `HTTP_${status}`;
  }
}

// Express 範例
const express = require('express');
const app = express();

app.get('/resource/:id', (req, res, next) => {
  const resource = database.find(req.params.id);
  if (!resource) {
    // 拋出自訂 404 錯誤
    return next(new HttpError(404, `資源 ${req.params.id} 不存在`));
  }
  res.json(resource);
});

// 統一錯誤處理中介軟體
app.use((err, req, res, next) => {
  if (err instanceof HttpError) {
    res.status(err.status).json({ code: err.code, message: err.message });
  } else {
    console.error(err);
    res.status(500).json({ code: 'UNKNOWN', message: '伺服器錯誤' });
  }
});

關鍵:在 Express 中把錯誤 next(err),讓統一的錯誤處理中介軟體負責回應,程式碼維護性大幅提升。


範例 4:多層級錯誤結構(AppError → DatabaseError)

class AppError extends Error {
  constructor(message, { code = 'APP_ERROR', status = 500, payload } = {}) {
    super(message);
    this.name = this.constructor.name;
    this.code = code;
    this.status = status;
    this.payload = payload;
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

class DatabaseError extends AppError {
  constructor(message, query) {
    super(message, { code: 'DB_ERROR', status: 500 });
    this.query = query; // 失敗的查詢語句
  }
}

// 模擬 DB 呼叫
function queryDatabase(sql) {
  // 假設查詢失敗
  throw new DatabaseError('資料庫執行錯誤', sql);
}

try {
  queryDatabase('SELECT * FROM users');
} catch (err) {
  if (err instanceof DatabaseError) {
    console.error(`代碼: ${err.code}, 查詢: ${err.query}`);
    // => 代碼: DB_ERROR, 查詢: SELECT * FROM users
  }
}

範例 5:在 Promise/async 中拋出自訂錯誤

class TimeoutError extends Error {
  constructor(message = '操作逾時') {
    super(message);
    this.name = 'TimeoutError';
    this.code = 'TIMEOUT';
  }
}

/**
 * 模擬一個可能超時的非同步任務
 * @param {number} ms 任務執行時間(毫秒)
 */
function asyncTask(ms) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      resolve('完成');
    }, ms);

    // 5 秒內未完成則拋出 TimeoutError
    setTimeout(() => {
      clearTimeout(timer);
      reject(new TimeoutError(`任務執行超過 ${ms}ms`));
    }, 5000);
  });
}

// 使用 async/await
(async () => {
  try {
    const result = await asyncTask(8000); // 故意讓它超時
    console.log(result);
  } catch (err) {
    if (err instanceof TimeoutError) {
      console.warn(`捕獲到超時錯誤:${err.message}`);
    } else {
      console.error(err);
    }
  }
})();

實務觀點:在非同步流程中,instanceof 同樣適用,只要保證錯誤類別在同一個執行環境中被引用。


常見陷阱與最佳實踐

陷阱 說明 解決方式
忘記呼叫 super(message) 不呼叫父類別建構子會導致 stackundefined,且錯誤訊息不會正確傳遞。 必須在子類別建構子第一行 super(message)
this.name 沒設正確 Errorname 預設是 "Error",若不改會讓日誌看不出錯誤類型。 設定 this.name = this.constructor.name,或手動指定字串。
跨執行環境 instanceof 失效 在 iframe、Web Worker、Node worker_threads 中,instanceof 無法正確判斷。 改用 err.name === 'MyError' 或檢查自訂屬性 (err.code).
堆疊資訊缺失 部分舊版瀏覽器不支援 Error.captureStackTracestack 可能不完整。 在支援的環境使用 Error.captureStackTrace(this, this.constructor);不支援時可自行組裝 stack
拋出原始字串或物件 只拋出字串或普通物件會失去錯誤類別與堆疊資訊。 永遠拋出 Error 或其子類別的實例。

最佳實踐

  1. 統一基礎錯誤類別
    為專案建立一個 AppError(或 BaseError)作為所有自訂錯誤的根類別,方便未來在全局捕獲時一次處理。

  2. 錯誤代碼(code)與 HTTP 狀態碼(status)分離

    • code 用於業務層面的錯誤識別(如 VALIDATION_ERRDB_CONN_FAIL)。
    • status 僅在與 HTTP 互動時才使用,保持錯誤本身的通用性。
  3. 錯誤訊息與使用者訊息分離

    • message 給開發者除錯用。
    • 若要回傳給前端,請在錯誤類別中加入 userMessage 或在捕獲層自行翻譯。
  4. 在中間層(Middleware)統一處理
    在 Express、Koa、NestJS 等框架中,建立一個全域錯誤處理器,根據 err instanceoferr.code 統一回應。

  5. 記錄(logging)時保留完整 stack
    使用 winstonpino 等日誌套件,將 err.stack 一併寫入,方便事後追蹤。


實際應用場景

1. 表單驗證(前端 & 後端)

  • 前端:在 Vue/React 表單提交前,若驗證失敗拋出 ValidationError,捕獲後直接在 UI 顯示欄位錯誤。
  • 後端:API 收到不合法的 JSON 時,拋出相同的 ValidationError,統一返回 400 Bad Request,前端僅需根據錯誤代碼映射 UI。

2. 第三方 API 呼叫失敗

class ExternalServiceError extends AppError {
  constructor(serviceName, status, details) {
    super(`${serviceName} 回傳錯誤`, {
      code: `${serviceName.toUpperCase()}_ERR`,
      status,
      payload: details,
    });
    this.serviceName = serviceName;
  }
}
  • 呼叫 Stripe、AWS、Firebase 等外部服務時,若回傳非 2xx,拋出 ExternalServiceError,在全局捕獲後決定是否重試或回報給使用者。

3. 資料庫操作錯誤

  • 依據錯誤類型(連線失敗、語法錯誤、唯一鍵衝突)拋出 DatabaseErrorDuplicateKeyError 等子類別,讓服務層可根據錯誤類別返回不同的 HTTP 狀態(如 409 Conflict)。

4. 工作排程(Cron / Queue)

  • 在背景工作(Node.js worker、RabbitMQ consumer)中,若任務因資源不足而失敗,拋出 RetryableError,在外層捕獲後將任務重新放回佇列。

5. API 金鑰或權限驗證

class AuthError extends AppError {
  constructor(message = '未授權') {
    super(message, { code: 'AUTH_ERR', status: 401 });
    this.name = 'AuthError';
  }
}
  • 在中介層檢查 JWT、API Key 時,若驗證失敗直接拋出 AuthError,統一回傳 401 Unauthorized

總結

自訂錯誤類型不僅是 程式碼可讀性 的提升,更是 除錯與錯誤治理 的關鍵工具。透過繼承 Error、加入錯誤代碼、狀態碼與自訂屬性,我們可以:

  1. 快速定位問題instanceof 讓錯誤類型一目了然。
  2. 提供結構化資訊:前端、日誌、監控系統皆可直接使用 codestatuspayload
  3. 統一錯誤處理流程:在中間層或全局捕獲器中一次處理所有自訂錯誤,減少重複程式碼。
  4. 提升使用者體驗:將技術錯誤與使用者可讀訊息分離,避免將堆疊資訊直接暴露給前端。

在日常開發中,建議先建立一個 基礎錯誤類別(如 AppError),再根據業務需求逐步擴充子類別;同時配合適當的日誌與監控,讓錯誤不再是「未知」的噩夢,而是可控、可追蹤的資訊。

祝你在 JavaScript 的錯誤與除錯旅程中,能夠寫出更乾淨、更穩定的程式碼! 🚀