本文 AI 產出,尚未審核

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

錯誤格式統一:statusCode、message、details


簡介

在 Web API 開發中,錯誤回傳的結構直接影響前端的除錯效率與使用者體驗。若每個路由都自行組成錯誤訊息,最終會出現「錯誤資訊不一致、難以追蹤」的問題。
本單元聚焦於 統一錯誤格式,以 statusCode、message、details 三個欄位為標準,讓所有錯誤都遵守同一套規則,無論是自訂例外、驗證失敗或是未捕獲的例外,都能被 Express 的全域錯誤中介軟體 (error‑middleware) 一次性處理。

統一的錯誤結構不僅能讓前端 一次性解析,還能在日後的 日誌系統、監控平台 中直接使用相同欄位做分析與警示,極大提升專案的可維護性與可觀測性。


核心概念

1. 為什麼要自訂 Error 類別?

Node.js 原生的 Error 只提供 message 與 stack,缺少 HTTP 狀態碼與額外資料。
在 TypeScript 中,我們可以擴充 Error,加入 statusCode 與 details,讓 型別安全 成為可能。

// src/errors/ApiError.ts
export class ApiError extends Error {
  /** HTTP 狀態碼,例如 400、404、500 */
  public readonly statusCode: number;
  /** 除了主要訊息外的補充資訊,通常是陣列或物件 */
  public readonly details?: unknown;

  constructor(statusCode: number, message: string, details?: unknown) {
    super(message);
    this.statusCode = statusCode;
    this.details = details;

    // 保留正確的 prototype 鏈(在 TypeScript 中必要)
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

重點:Object.setPrototypeOf 必須放在建構子裡,否則 instanceof ApiError 會失效。


2. 建立全域錯誤中介軟體

Express 只要在最後一層掛上 (err, req, res, next) 的函式,就能捕捉到所有未處理的例外。以下範例會把 ApiError 與其他錯誤都轉成統一格式回傳。

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

export function errorHandler(
  err: unknown,
  _req: Request,
  res: Response,
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  _next: NextFunction,
) {
  // 1️⃣ 判斷是否為我們自訂的 ApiError
  if (err instanceof ApiError) {
    return res.status(err.statusCode).json({
      statusCode: err.statusCode,
      message: err.message,
      details: err.details ?? null,
    });
  }

  // 2️⃣ 其他非預期錯誤(例如程式碼 bug)
  console.error('未捕獲的例外:', err);
  return res.status(500).json({
    statusCode: 500,
    message: '系統內部錯誤,請稍後再試。',
    details: null,
  });
}

技巧:在正式環境可以把 console.error 換成 Winston、Pino 等日誌套件,並加入錯誤 ID 供前端追蹤。


3. 使用 async 包裝器避免忘記 next(err)

在 Express 中,async 路由若直接拋出例外,Express 不會自動捕捉,需要自行呼叫 next(err)。以下提供一個高階函式 asyncHandler,讓所有 async 控制器都自動傳遞錯誤。

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

/**
 * 把 async route handler 包裝成符合 Express 錯誤機制的函式
 */
export const asyncHandler = (fn: RequestHandler) => (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

使用方式:

// src/controllers/userController.ts
import { Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { ApiError } from '../errors/ApiError';
import { UserModel } from '../models/User';

export const getUser = asyncHandler(async (req: Request, res: Response) => {
  const user = await UserModel.findById(req.params.id);
  if (!user) {
    // 拋出自訂錯誤,會被 errorHandler 統一格式化
    throw new ApiError(404, '找不到使用者', { userId: req.params.id });
  }
  res.json(user);
});

4. Validation(驗證)錯誤的統一處理

常見的驗證套件(如 class-validator、joi)會回傳一長串錯誤訊息。把它們轉成 details 陣列,可讓前端直接呈現欄位與錯誤說明。

// src/middleware/validationHandler.ts
import { Request, Response, NextFunction } from 'express';
import { ValidationError, validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { ApiError } from '../errors/ApiError';

/**
 * 產生一個可重用的驗證中介軟體
 * @param type DTO class
 */
export const validationHandler = (type: any) => async (
  req: Request,
  _res: Response,
  next: NextFunction,
) => {
  const dto = plainToInstance(type, req.body);
  const errors: ValidationError[] = await validate(dto);
  if (errors.length > 0) {
    const details = errors.map(err => ({
      property: err.property,
      constraints: err.constraints,
    }));
    return next(new ApiError(400, '請求參數驗證失敗', details));
  }
  // 若驗證通過,將 DTO 放回 req.body 供後續使用
  req.body = dto;
  next();
};

使用範例:

// src/dto/CreateUserDto.ts
import { IsEmail, IsString, Length } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email!: string;

  @IsString()
  @Length(6, 20)
  password!: string;
}

// src/routes/userRoutes.ts
import { Router } from 'express';
import { createUser } from '../controllers/userController';
import { validationHandler } from '../middleware/validationHandler';
import { CreateUserDto } from '../dto/CreateUserDto';

const router = Router();

router.post(
  '/users',
  validationHandler(CreateUserDto),
  createUser,
);

export default router;

5. 統一回傳範例(成功與錯誤)

為了保持 API 風格一致,建議在成功回傳時也使用類似結構,只是 statusCode 為 200、message 為 OK,data 放在 details 或額外欄位。

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

export const responseWrapper = (
  _req: Request,
  res: Response,
  next: NextFunction,
) => {
  const oldJson = res.json;
  // 攔截 res.json,包裝成統一格式
  res.json = (data: any) => {
    const payload = {
      statusCode: res.statusCode,
      message: res.statusCode === 200 ? 'OK' : 'Error',
      details: data,
    };
    return oldJson.call(res, payload);
  };
  next();
};

在 app.ts 中掛載:

import express from 'express';
import { errorHandler } from './middleware/errorHandler';
import { responseWrapper } from './middleware/responseWrapper';
import userRoutes from './routes/userRoutes';

const app = express();

app.use(express.json());
app.use(responseWrapper);   // 先掛載
app.use('/api', userRoutes);

// 最後掛載錯誤中介軟體
app.use(errorHandler);

export default app;

常見陷阱與最佳實踐

陷阱 說明 解決方案
忘記 next(err) 在 async 控制器內直接 throw,但未使用包裝器,導致錯誤被吞掉。 使用 asyncHandler 或手動 catch 後 next(err)。
錯誤物件被改寫 中間件不小心把 err 的屬性覆寫,造成原始資訊遺失。 只在 errorHandler 中讀取屬性,避免修改。
details 內容過大 把整個資料庫模型塞入 details,回傳大量不必要資訊。 只回傳與錯誤相關的最小資訊(欄位名稱、驗證規則、錯誤碼)。
未考慮非 HTTP 錯誤 例如資料庫連線失敗,沒有 statusCode。 在 errorHandler 中針對非 ApiError 統一回傳 500,並在日誌中加入錯誤類型。
多語系訊息 前端需要多語系訊息,但 message 只寫中文。 把 message 改成錯誤代碼(如 USER_NOT_FOUND),再由前端根據語系取對應文字。

最佳實踐:

  1. 所有自訂例外都繼承 ApiError,確保 statusCode 必定存在。
  2. 統一使用 asyncHandler 包裝所有 async 路由,避免遺漏 next(err)。
  3. 在錯誤中介軟體最後 再回傳 JSON,確保所有回應皆走同一條路。
  4. 將 details 設計為可選,只在需要時才提供,減少不必要的流量。
  5. 結合日誌與錯誤追蹤(Sentry、Logstash),把 err.stack、req.id、userId 等資訊寫入,方便事後排查。

實際應用場景

場景一:使用者註冊時的驗證錯誤

前端送出:

{
  "email": "invalid-email",
  "password": "123"
}

API 回傳:

{
  "statusCode": 400,
  "message": "請求參數驗證失敗",
  "details": [
    {
      "property": "email",
      "constraints": {
        "isEmail": "email 必須是有效的電子郵件地址"
      }
    },
    {
      "property": "password",
      "constraints": {
        "length": "password 長度必須在 6 到 20 之間"
      }
    }
  ]
}

前端只需要遍歷 details,即可在對應欄位顯示錯誤訊息。

場景二:資源未找到(404)

{
  "statusCode": 404,
  "message": "找不到使用者",
  "details": {
    "userId": "62f8c9d5c1a2b34e5d9f1a7b"
  }
}

前端根據 statusCode 判斷導向 404 頁面,details 讓開發者快速定位是哪個參數導致錯誤。

堲景三:系統內部錯誤(500)+ 錯誤追蹤 ID

{
  "statusCode": 500,
  "message": "系統內部錯誤,請稍後再試。",
  "details": {
    "errorId": "20251125-abc123-def456"
  }
}

後端在 errorHandler 中產生唯一的 errorId(可使用 uuid),寫入日誌與外部追蹤服務,前端把 errorId 顯示給使用者或回報支援團隊。


總結

  • 統一錯誤格式(statusCode、message、details)是提升 API 可維護性、可觀測性的關鍵。
  • 透過 自訂 ApiError、全域錯誤中介軟體、async 包裝器,可以在 Express + TypeScript 中輕鬆落實此規範。
  • 驗證錯誤、業務例外、未捕獲例外 都會被自動包裝成同樣的 JSON 結構,讓前端只需一次性解析。
  • 注意常見陷阱(忘記 next(err)、過大 details、多語系需求),並遵循最佳實踐(所有例外繼承 ApiError、日誌與錯誤追蹤結合)。

將上述模式套用到實際專案後,你會發現 錯誤除錯時間縮短、前端開發效率提升,且系統的穩定度與可觀測性也同步提升。祝開發順利,錯誤不再是阻礙,而是提升產品品質的契機!