本文 AI 產出,尚未審核

ExpressJS (TypeScript) – Middleware 概念與使用

主題:錯誤處理 Middleware


簡介

Express 中,Middleware 是請求/回應生命週期的核心組件,負責攔截、加工或終止 HTTP 請求。雖然大多數教學會先講解驗證、日誌或路由分派,但 錯誤處理 Middleware 才是真正保證服務穩定、使用者體驗良好的關鍵。

  • 若程式碼在任一階段拋出例外而未被捕捉,整個 Node.js 進程可能直接崩潰,造成服務中斷。
  • 透過統一的錯誤處理 Middleware,我們可以把錯誤轉成 一致的 HTTP 回應(例如 JSON 錯誤資訊),同時記錄必要的除錯資訊。

本篇文章將以 TypeScript 為開發語言,深入說明錯誤處理 Middleware 的原理、實作方式、常見陷阱與最佳實踐,並提供多個可直接套用的範例,讓你在實務專案中快速上手。


核心概念

1. 錯誤處理 Middleware 的簽名

普通 Middleware 的函式簽名為 (req, res, next) => {},而 錯誤處理 Middleware 必須多一個 err 參數:

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

function errorHandler(err: any, req: Request, res: Response, next: NextFunction) {
  // 處理錯誤
}

只要 next(err) 被呼叫,或是同步程式碼拋出例外,Express 會自動把控制權交給最近的錯誤處理 Middleware。

重點:錯誤處理 Middleware 必須放在所有路由與普通 Middleware 之後,否則不會被觸發。


2. 何時使用 next(err)

  • 同步程式碼:直接 throw new Error('...'),Express 會捕捉並傳遞給錯誤處理 Middleware。
  • 非同步程式碼(Promise / async/await):需要手動 catchnext(err),或是直接在 async 函式中拋出,Express 會自動捕獲(自 v5 起;v4 需要自行包裝)。
// 同步範例
app.get('/sync', (req, res, next) => {
  if (!req.query.id) {
    // 直接拋出例外,Express 會自動轉成錯誤
    throw new Error('Missing id parameter');
  }
  res.send('OK');
});

// 非同步範例(async/await)
app.get('/async', async (req, res, next) => {
  try {
    const data = await someAsyncOperation();
    res.json(data);
  } catch (e) {
    // 必須呼叫 next(e) 才會走錯誤 Middleware
    next(e);
  }
});

3. 統一錯誤格式

在大型專案中,前端往往期待固定格式的錯誤回應,例如:

{
  "status": 400,
  "code": "INVALID_PARAMETER",
  "message": "Missing required field: id",
  "details": null
}

透過錯誤處理 Middleware,我們可以將所有例外統一轉換成上述結構。

interface ApiError {
  status: number;          // HTTP 狀態碼
  code: string;            // 自訂錯誤代碼
  message: string;         // 使用者可讀訊息
  details?: any;           // 進階除錯資訊(僅在開發環境回傳)
}

4. 建立自訂 Error 類別

為了在 next(err) 時攜帶更多資訊,建議自訂錯誤類別:

// src/errors/HttpError.ts
export class HttpError extends Error {
  public status: number;
  public code: string;
  public details?: any;

  constructor(status: number, code: string, message: string, details?: any) {
    super(message);
    this.status = status;
    this.code = code;
    this.details = details;
    // 保留正確的 prototype 鏈(在 TypeScript 中必要)
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

在路由中直接拋出:

import { HttpError } from './errors/HttpError';

app.get('/user/:id', (req, res, next) => {
  const user = findUser(req.params.id);
  if (!user) {
    // 交給錯誤 Middleware 處理
    return next(new HttpError(404, 'USER_NOT_FOUND', `User ${req.params.id} not found`));
  }
  res.json(user);
});

5. 完整錯誤處理 Middleware 範例

以下是一個 可直接掛載 的錯誤處理 Middleware,支援自訂錯誤、未知錯誤與開發/正式環境的差異:

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

export function errorHandler(
  err: any,
  _req: Request,
  res: Response,
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  _next: NextFunction
) {
  // 1️⃣ 判斷是否為我們自訂的 HttpError
  const isHttpError = err instanceof HttpError;

  // 2️⃣ 設定回傳的 HTTP 狀態碼(預設 500)
  const status = isHttpError ? err.status : 500;

  // 3️⃣ 組成統一的錯誤結構
  const responseBody = {
    status,
    code: isHttpError ? err.code : 'INTERNAL_SERVER_ERROR',
    message: isHttpError ? err.message : '系統發生未預期的錯誤',
    // 只在開發環境回傳 stack,避免資訊外洩
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
    // 若有額外細節資料也一起回傳
    ...(isHttpError && err.details ? { details: err.details } : {})
  };

  // 4️⃣ 記錄錯誤(可自行接入 Winston、Pino 等 logger)
  console.error(`[${new Date().toISOString()}]`, err);

  // 5️⃣ 回傳 JSON 給前端
  res.status(status).json(responseBody);
}

app.ts 中掛載:

import express from 'express';
import { errorHandler } from './middleware/errorHandler';

const app = express();

// ... 其他 middleware / routes

// 最後掛載錯誤處理 middleware
app.use(errorHandler);

export default app;

程式碼範例(實用 5 篇)

範例 1:捕捉未處理的 Promise 拒絕

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

/**
 * 包裝 async route handler,使其自動呼叫 next(err)
 */
export const asyncWrapper = (fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) =>
  (req: Request, res: Response, next: NextFunction) => {
    fn(req, res, next).catch(next);
  };

使用方式:

import { asyncWrapper } from './utils/asyncWrapper';

app.get(
  '/posts',
  asyncWrapper(async (req, res) => {
    const posts = await PostModel.find(); // 若此處拋錯會自動交給 errorHandler
    res.json(posts);
  })
);

範例 2:驗證失敗時拋出 400 錯誤

import { HttpError } from './errors/HttpError';
import { body, validationResult } from 'express-validator';

app.post(
  '/register',
  [
    body('email').isEmail().withMessage('Email 格式不正確'),
    body('password').isLength({ min: 6 }).withMessage('密碼至少 6 個字元')
  ],
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      // 把驗證錯誤集合轉成自訂錯誤
      return next(
        new HttpError(
          400,
          'VALIDATION_ERROR',
          '請求參數驗證失敗',
          errors.array()
        )
      );
    }
    // 正常註冊流程...
    res.json({ message: '註冊成功' });
  }
);

範例 3:全域捕捉未處理例外(process)

process.on('unhandledRejection', (reason: any) => {
  console.error('未處理的 Promise 拒絕:', reason);
  // 可選擇寫入 log、發送警報或 graceful shutdown
});

process.on('uncaughtException', (err: Error) => {
  console.error('未捕捉的例外:', err);
  // 建議在此處關閉 server,避免不一致狀態
  process.exit(1);
});

範例 4:分層錯誤處理(路由層 + 全域層)

// routes/user.ts
import { Router } from 'express';
import { HttpError } from '../errors/HttpError';
import { asyncWrapper } from '../utils/asyncWrapper';

const router = Router();

router.get(
  '/:id',
  asyncWrapper(async (req, res, next) => {
    const user = await UserService.getById(req.params.id);
    if (!user) {
      // 只在此路由層拋出 404,讓全域 errorHandler 統一回應
      throw new HttpError(404, 'USER_NOT_FOUND', `找不到 ID 為 ${req.params.id} 的使用者`);
    }
    res.json(user);
  })
);

export default router;

app.ts 中:

import userRouter from './routes/user';
app.use('/users', userRouter);
// 其他路由...
app.use(errorHandler); // 全域錯誤處理

範例 5:自訂錯誤日誌(結合 Winston)

// src/logger.ts
import winston from 'winston';

export const logger = winston.createLogger({
  level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [new winston.transports.Console()]
});

errorHandler 中使用:

import { logger } from '../logger';

export function errorHandler(err: any, _req: Request, res: Response, _next: NextFunction) {
  const status = err instanceof HttpError ? err.status : 500;
  logger.error('API Error', {
    message: err.message,
    status,
    stack: err.stack,
    // 其他自訂欄位
  });

  // 其餘回應同前述範例
}

常見陷阱與最佳實踐

陷阱 說明 解決方案
忘記在非同步路由呼叫 next(err) async 函式拋出錯誤卻未被捕獲,導致請求懸掛。 使用 asyncWrapper 或在每個 catchnext(err)
錯誤處理 Middleware 放在路由之前 Express 找不到可用的錯誤處理程式,錯誤直接傳到 Node.js。 確保 app.use(errorHandler) 位於所有路由之最後
回傳錯誤資訊過於詳細 在正式環境將 stack、SQL 語句等敏感資訊洩漏給外部。 只在 process.env.NODE_ENV === 'development' 時回傳除錯資訊。
未處理 Promise 拒絕 Node.js 會印出警告,甚至在未捕獲時退出。 全域監聽 unhandledRejection,或使用 asyncWrapper
自訂 Error 類別未正確繼承 Error instanceof 判斷失敗,導致錯誤被當成 500 處理。 在建構子內使用 Object.setPrototypeOf(this, new.target.prototype);

最佳實踐

  1. 統一錯誤格式:前端只要依賴一套結構,就能簡化錯誤 UI。
  2. 分層處理:路由層只拋出自訂錯誤,全域層負責日誌、回應、隱私控制。
  3. 使用型別:在 TypeScript 中定義 HttpErrorApiError 介面,讓 IDE 能提示正確屬性。
  4. 記錄完整堆疊:使用 Winston、Pino 或 Bunyan 記錄錯誤,便於日後追蹤。
  5. Graceful Shutdown:遇到 uncaughtException 時,先關閉 server、完成 pending 請求,再退出。

實際應用場景

  1. RESTful API:所有 CRUD 操作若發生驗證失敗、資源不存在或資料庫錯誤,都透過錯誤 Middleware 統一回傳 JSON,前端只要根據 code 處理 UI。
  2. 第三方服務整合:調用外部 API 時,若回傳非 2xx 狀態,拋出自訂 HttpError(502, 'EXTERNAL_SERVICE_ERROR', ...),讓使用者知道是外部系統問題。
  3. 多租戶 SaaS 平台:不同租戶可能有不同的錯誤代碼映射,錯誤 Middleware 可根據 req.tenantId 加上額外 details,供監控平台做統計。
  4. 微服務間通訊:在 Express 作為 API Gateway 時,錯誤 Middleware 會把下游服務的錯誤重新包裝,避免內部錯誤直接洩漏。

總結

錯誤處理 Middleware 是 Express 應用的安全防線與使用者體驗保證。透過 統一的錯誤結構自訂 Error 類別asyncWrapper 等技巧,我們可以:

  • 避免未捕獲例外導致服務崩潰
  • 提供前端一致、易於解析的錯誤回應
  • 在開發與正式環境間安全地切換除錯資訊
  • 將錯誤日誌與監控集中管理

掌握上述概念後,你的 TypeScript + Express 專案將更加 穩健、可維護,也能在面對複雜業務需求時,保持良好的錯誤治理。祝開發順利,快把這套錯誤處理機制寫進你的下一個專案吧!