本文 AI 產出,尚未審核

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

錯誤格式統一:statusCodemessagedetails


簡介

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

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


核心概念

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

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

// 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-validatorjoi)會回傳一長串錯誤訊息。把它們轉成 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、messageOKdata 放在 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 或手動 catchnext(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.stackreq.iduserId 等資訊寫入,方便事後排查。

實際應用場景

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

前端送出:

{
  "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 顯示給使用者或回報支援團隊。


總結

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

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