本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 錯誤處理與例外管理

主題:Error Object 設計(自訂 Error Class)


簡介

在任何 Web API 中,錯誤的捕捉與回傳都是使用者體驗與系統穩定性的關鍵。Express 內建 next(err) 機制可以把例外交給全域錯誤處理器,但如果直接拋出原生的 Error,往往只能得到模糊的訊息,對前端或日誌系統都不友好。

透過 自訂 Error Class,我們可以在錯誤物件裡加入 HTTP 狀態碼、錯誤代碼、可序列化的資料等欄位,讓錯誤在傳遞、記錄、以及回傳給客戶端時都保持一致且可讀。本文將以 Express + TypeScript 為例,說明如何設計、使用與最佳化自訂錯誤類別,幫助初學者快速上手,同時提供中階開發者在大型專案中統一錯誤格式的實務技巧。


核心概念

1️⃣ 為什麼要自訂 Error Class?

  • 統一介面:所有錯誤都遵循同一個結構(status, code, message, data),中間件只需要檢查這些屬性即可。
  • 可擴充:未來若要加入 errorIdtraceId 或客製化的 logLevel,只要在基底類別上新增即可,所有子類別自動繼承。
  • 型別安全:使用 TypeScript 時,透過介面或抽象類別能在編譯期就捕捉錯誤屬性遺漏的問題。

2️⃣ 基礎抽象類別 AppError

// src/errors/AppError.ts
export abstract class AppError extends Error {
  /** HTTP 狀態碼,預設 500 */
  public readonly status: number;
  /** 業務層自訂錯誤代碼,方便前端對照 */
  public readonly code: string;
  /** 可選的額外資料,會在回傳 JSON 時一起帶出 */
  public readonly data?: unknown;

  constructor(message: string, status = 500, code = 'UNKNOWN_ERROR', data?: unknown) {
    super(message);
    this.status = status;
    this.code = code;
    this.data = data;
    // 讓 instanceof 判斷正確
    Object.setPrototypeOf(this, new.target.prototype);
    // 捕捉 stack trace(Node.js 專用)
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor);
    }
  }

  /** 轉換成 API 回傳格式 */
  public toResponse() {
    return {
      error: {
        code: this.code,
        message: this.message,
        ...(this.data && { data: this.data })
      }
    };
  }
}
  • abstract:不允許直接實例化,只能被繼承。
  • Object.setPrototypeOf:解決 TypeScript 在繼承內建 Errorinstanceof 判斷失效的問題。
  • toResponse():將錯誤物件轉成前端友好的 JSON 結構,所有錯誤類別皆可直接呼叫。

3️⃣ 常見的子類別

3.1 ValidationError(驗證失敗)

// src/errors/ValidationError.ts
import { AppError } from './AppError';

export class ValidationError extends AppError {
  constructor(message: string, data?: Record<string, any>) {
    super(message, 400, 'VALIDATION_ERROR', data);
  }
}

3.2 NotFoundError(資源找不到)

// src/errors/NotFoundError.ts
import { AppError } from './AppError';

export class NotFoundError extends AppError {
  constructor(resource: string, id: string | number) {
    super(`${resource} (${id}) not found`, 404, 'NOT_FOUND');
  }
}

3.3 UnauthorizedError(未授權)

// src/errors/UnauthorizedError.ts
import { AppError } from './AppError';

export class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') {
    super(message, 401, 'UNAUTHORIZED');
  }
}

4️⃣ 在路由中拋出自訂錯誤

// src/routes/user.ts
import { Router, Request, Response, NextFunction } from 'express';
import { ValidationError, NotFoundError } from '../errors';
import { getUserById } from '../services/userService';

const router = Router();

router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
  const { id } = req.params;
  if (!/^\d+$/.test(id)) {
    // 直接拋出自訂錯誤,交給全域錯誤處理器
    return next(new ValidationError('User ID must be a numeric string', { id }));
  }

  try {
    const user = await getUserById(Number(id));
    if (!user) {
      throw new NotFoundError('User', id);
    }
    res.json({ data: user });
  } catch (err) {
    next(err); // 交給下方的 error‑handler
  }
});

export default router;

5️⃣ 全域錯誤處理 Middleware

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/AppError';

export function errorHandler(
  err: unknown,
  _req: Request,
  res: Response,
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  _next: NextFunction
) {
  // 若是我們自訂的錯誤類別,直接使用其屬性
  if (err instanceof AppError) {
    const { status, toResponse } = err;
    return res.status(status).json(toResponse());
  }

  // 其他非預期錯誤,僅回傳 500,並在伺服器端記錄
  console.error(err);
  return res.status(500).json({
    error: {
      code: 'INTERNAL_SERVER_ERROR',
      message: '系統發生未預期的錯誤,請稍後再試'
    }
  });
}

app.ts 中最後掛上:

import express from 'express';
import userRouter from './routes/user';
import { errorHandler } from './middleware/errorHandler';

const app = express();

app.use(express.json());
app.use('/users', userRouter);

// 必須放在所有路由之後
app.use(errorHandler);

export default app;

常見陷阱與最佳實踐

陷阱 現象 解決方式
忘記 Object.setPrototypeOf instanceof ValidationErrorfalse,導致錯誤無法被正確捕捉 在基底類別建構子中加入 Object.setPrototypeOf(this, new.target.prototype);
直接回傳原生 Error 前端只能收到 message,缺少狀態碼與代碼 只拋出自訂的 AppError 子類別,或在全域錯誤處理器中將原生錯誤包裝成 AppError
錯誤資訊過度暴露 生產環境回傳 stack trace、資料庫錯誤訊息等 errorHandler 中根據 process.env.NODE_ENV 決定回傳內容,僅保留 code & message
未設定 status 預設回傳 200,導致客戶端認為請求成功 基底類別預設 500,子類別明確指定正確的 HTTP 狀態碼
忘記 next(err) 錯誤被 swallow,請求卡住或回傳 200 空白 在 async route 中使用 try/catchnext(err),或直接回傳 Promise.reject(err)

最佳實踐

  1. 所有錯誤都繼承自 AppError:即使是第三方套件拋出的錯誤,也可在全域處理器中包裝一次。
  2. 錯誤代碼 (code) 使用全大寫、底線分隔,方便前端列舉 enum
  3. 將業務相關資訊放在 data,例如驗證失敗的欄位列表、分頁參數錯誤等。
  4. 在測試環境,可利用 toResponse() 直接驗證回傳結構,而不必跑完整的 HTTP 請求。
  5. 加入唯一的 errorId(UUID)於每筆錯誤,便於日誌追蹤與客戶支援。

實際應用場景

場景一:RESTful API 的統一錯誤回傳

在大型電商平台中,前端需要根據錯誤代碼顯示不同的 UI(例如 401 需要跳轉登入、403 顯示權限不足、400 顯示表單驗證錯誤)。透過自訂錯誤類別,後端只要拋出對應的錯誤,前端即可透過 error.code 做一致的處理。

場景二:微服務間的錯誤傳遞

假設有 order-service 呼叫 inventory-service,若庫存不足,inventory-service 會拋出 ValidationError('Insufficient stock', { productId, requested, available })。在 order-service 收到錯誤後,只要把同樣的 AppError 重新拋出或包裝,就能保留原始的 codedata,讓最終的 API 使用者得到完整資訊。

場景三:集中式日誌與監控

使用 winstonpino 實作日誌時,只要在 errorHandler 中檢查 instanceof AppError,即可將 status, code, message, datastack 結構化寫入 Elasticsearch 或 Loki,方便後續的搜尋與告警。


總結

  • 自訂 Error Class 為 Express + TypeScript 專案提供了「統一、可擴充、型別安全」的錯誤模型。
  • 透過抽象基底 AppError,我們只需在子類別裡定義 HTTP 狀態碼業務代碼可選資料,即可在全域錯誤處理器中一次搞定回傳與日誌。
  • 注意 prototypestatus 預設值、以及 環境別的錯誤資訊,可避免常見陷阱。
  • 在實務上,這套機制不僅讓前端 UI 能快速對錯誤做出回應,也讓微服務間的錯誤傳遞與集中式監控變得更簡潔。

掌握了自訂錯誤類別的設計與使用,你的 Express API 將會變得更可靠易維護,也更能符合企業級開發的品質要求。祝開發順利!