本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 統一的 Error Middleware 結構


簡介

在大型的 Node.js/Express 專案中,錯誤處理往往是最容易被忽略、卻最關鍵的部分。若每個路由、服務或第三方套件都自行拋出錯誤,而沒有統一的處理機制,伺服器很容易回傳不一致的錯誤訊息,甚至導致程式崩潰、資源洩漏或安全漏洞。

使用 TypeScript 能讓我們在編譯階段就捕捉大部分錯誤型別,但運行時仍會出現例外(Exception)或 Promise 拒絕(rejection)。因此,建立一套 統一的 error middleware,不僅能保證 API 回傳格式的一致性,還能集中記錄、分類與回傳適當的 HTTP 狀態碼,提升除錯效率與使用者體驗。

本篇文章將說明如何在 Express + TypeScript 專案中設計、實作與最佳化錯誤中介層,讓你的應用程式在面對各種錯誤時都能保持「乾淨、可預測」的行為。


核心概念

1️⃣ 為何要統一 Error Middleware

常見問題 若沒有統一機制的結果
錯誤訊息格式不一致 前端需要寫多套解析程式,維護成本升高
錯誤被吞掉或未回傳 使用者得到 200 OK 但實際失敗
未記錄錯誤 生產環境難以追蹤問題根源
敏感資訊外洩 堆疊資訊直接回傳給使用者,可能暴露內部實作

結論:一個 集中式型別安全 的 error middleware 能一次解決上述問題。


2️⃣ Error Middleware 的基本結構

在 Express 中,錯誤中介層的簽名必須為 (err, req, res, next),且必須放在所有路由之。使用 TypeScript 時,我們可以利用 Error 的子類別來攜帶額外資訊(如 HTTP 狀態碼、錯誤代碼)。

import { Request, Response, NextFunction } from 'express';

/** 基礎的自訂錯誤類別 */
export class HttpError extends Error {
  public status: number;
  public code?: string; // 可自行定義錯誤代碼,例如 'USER_NOT_FOUND'

  constructor(status: number, message: string, code?: string) {
    super(message);
    this.status = status;
    this.code = code;
    // 必須手動設定原型鏈,避免 instanceof 判斷失效
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

/** 統一的 error middleware */
export const errorHandler = (
  err: unknown,
  _req: Request,
  res: Response,
  _next: NextFunction,
) => {
  // 1️⃣ 先判斷是否為自訂的 HttpError
  if (err instanceof HttpError) {
    return res.status(err.status).json({
      success: false,
      error: {
        message: err.message,
        code: err.code ?? 'UNKNOWN_ERROR',
      },
    });
  }

  // 2️⃣ 再處理其他已知錯誤類型(例如 ValidationError)
  // ...(此處可自行擴充)

  // 3️⃣ 未知錯誤 => 內部伺服器錯誤
  console.error(err); // **務必記錄**,避免錯誤沉默
  return res.status(500).json({
    success: false,
    error: {
      message: '系統發生未預期的錯誤,請稍後再試。',
      code: 'INTERNAL_SERVER_ERROR',
    },
  });
};

重點

  • 使用 instanceof 判斷自訂錯誤類別,確保型別安全。
  • 所有錯誤都要走同一條回傳路徑,前端只需要處理一次 success: false 的結構。

3️⃣ 在路由、服務層拋出錯誤

範例 1:簡單的參數驗證錯誤

import { Request, Response, NextFunction } from 'express';
import { HttpError } from '../middlewares/errorHandler';

export const getUser = async (req: Request, res: Response, next: NextFunction) => {
  const { id } = req.params;
  if (!/^\d+$/.test(id)) {
    // 直接拋出自訂錯誤,交給 errorHandler 處理
    return next(new HttpError(400, '使用者 ID 必須為正整數', 'INVALID_USER_ID'));
  }

  // 正常流程...
};

範例 2:服務層的業務例外

// src/services/userService.ts
import { HttpError } from '../middlewares/errorHandler';
import { UserModel } from '../models/User';

export const findUserByEmail = async (email: string) => {
  const user = await UserModel.findOne({ email });
  if (!user) {
    // 這裡拋出錯誤,讓呼叫端不必自行判斷 null
    throw new HttpError(404, `找不到 email 為 ${email} 的使用者`, 'USER_NOT_FOUND');
  }
  return user;
};

範例 3:捕捉非同步錯誤的簡潔寫法

// src/utils/asyncWrapper.ts
import { Request, Response, NextFunction } from 'express';

/**
 * 將 async route handler 包裝成 (req, res, next) => void
 * 讓所有拋出的例外自動傳給 next()
 */
export const asyncWrapper = (fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) => {
  return (req: Request, res: Response, next: NextFunction) => {
    fn(req, res, next).catch(next);
  };
};
// 使用方式
import { asyncWrapper } from '../utils/asyncWrapper';
import { findUserByEmail } from '../services/userService';

router.get(
  '/by-email/:email',
  asyncWrapper(async (req, res) => {
    const user = await findUserByEmail(req.params.email);
    res.json({ success: true, data: user });
  })
);

4️⃣ 統一回傳格式

在多數團隊中,API 回傳規範 常採用以下結構:

{
  "success": true | false,
  "data": {...} | null,
  "error": {
    "message": "錯誤說明",
    "code": "錯誤代碼",
    "details": {...} // 可選,提供額外資訊
  }
}

因此,我們的 error middleware 會固定回傳 success: false 並放入 error 物件。只要在所有路由的成功回傳中遵守 success: truedata 欄位的慣例,前端就能 一次性解析 所有回應。


常見陷阱與最佳實踐

陷阱 說明 最佳實踐
忘記 next(err) 直接 throw 於非 async 函式會被 Node 捕捉,但不會自動傳給 middleware。 使用 asyncWrapper 或在同步路由中顯式 return next(err)
在 error middleware 中再次拋錯 會造成無限迴圈或 Express 無法回應。 永遠在最後一層回傳 res.status(...).json(...),不要再呼叫 next(err)
回傳完整 stack trace 會洩漏內部實作,危害安全。 只在 開發環境process.env.NODE_ENV === 'development')印出堆疊,正式環境僅回傳通用訊息。
錯誤類別不統一 不同模組拋出不同型別,難以集中處理。 建立 基礎錯誤類別(如 HttpError),所有自訂錯誤都繼承它。
忘記把 error middleware 放在最底 若放在路由之前,錯誤不會被捕捉。 app.use(...routes) 之後 最後 app.use(errorHandler)

額外技巧

  • 使用 class-validator + typeorm 時,可把驗證錯誤轉成 HttpError(400, ...)
  • Log 收斂:使用 winstonpino 把錯誤寫入檔案或外部監控平台(如 Sentry)。
  • 自訂錯誤代碼:在錯誤物件中加入 code,方便前端對應 UI 行為(例如顯示登入失敗 vs. 權限不足)。

實際應用場景

1️⃣ 多租戶 SaaS 平台

在 SaaS 系統中,同一套 API 會服務多個客戶(tenant)。若某個租戶的資料庫發生連線失敗,若未統一處理,可能會把 資料庫錯誤訊息(包含密碼)直接回傳給前端。使用統一的 error middleware,我們可以:

  1. 把所有 DB 相關錯誤包成 HttpError(503, '服務暫時無法使用', 'DB_CONNECTION_ERROR')
  2. 在 middleware 中根據 process.env.NODE_ENV 決定是否把堆疊寫入日誌。
  3. 前端只會收到 code: 'DB_CONNECTION_ERROR',再根據代碼顯示「系統維護中」的訊息。

2️⃣ 需要驗證 JWT 的保護路由

import jwt from 'jsonwebtoken';
import { HttpError } from '../middlewares/errorHandler';

export const authGuard = (req: Request, _res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) {
    return next(new HttpError(401, '未提供授權憑證', 'TOKEN_MISSING'));
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!);
    // 把使用者資訊掛在 req 上,供後續使用
    (req as any).user = payload;
    next();
  } catch (e) {
    return next(new HttpError(401, '授權憑證無效或已過期', 'TOKEN_INVALID'));
  }
};

所有需要驗證的路由只要 app.use(authGuard)錯誤訊息會由 error middleware 統一回傳,前端不必自行檢查 try/catch

3️⃣ 大型微服務間的錯誤轉譯

微服務 A 呼叫微服務 B,B 回傳 404 時,我們在 A 中捕捉到 AxiosError,再拋成 HttpError(404, '資源不存在', 'RESOURCE_NOT_FOUND') 給前端。這樣即使底層呼叫的服務改變,外部 API 的錯誤介面保持不變


總結

  • 統一的 error middleware 是 Express + TypeScript 專案的基石,能確保錯誤回傳格式、日誌紀錄與安全性的一致性。
  • 透過 自訂 HttpError 類別asyncWrapper型別安全的判斷,我們可以在路由、服務層、第三方呼叫中自由拋錯,卻不會讓錯誤「跑到」未知的地方。
  • 常見的坑(忘記 next(err)、回傳堆疊、錯誤類別不統一)只要遵循 最佳實踐(集中處理、區分開發/正式環境、使用日誌)即可輕鬆避免。
  • 真正的價值在於 實務應用:不論是 SaaS 多租戶、JWT 驗證、或微服務間的錯誤轉譯,都能藉由統一的錯誤機制讓前端開發者只需要一次性處理 success: false 的回應。

把這套 「錯誤 → 捕捉 → 統一回傳」 的流程寫入你的專案腳手架,未來的功能擴充、除錯與維護都會變得更順手、更安全。祝開發順利,錯誤不再是障礙,而是讓系統更穩固的助力!