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),再由前端根據語系取對應文字。 |
最佳實踐:
- 所有自訂例外都繼承
ApiError,確保statusCode必定存在。 - 統一使用
asyncHandler包裝所有 async 路由,避免遺漏next(err)。 - 在錯誤中介軟體最後 再回傳 JSON,確保所有回應皆走同一條路。
- 將
details設計為可選,只在需要時才提供,減少不必要的流量。 - 結合日誌與錯誤追蹤(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、日誌與錯誤追蹤結合)。
將上述模式套用到實際專案後,你會發現 錯誤除錯時間縮短、前端開發效率提升,且系統的穩定度與可觀測性也同步提升。祝開發順利,錯誤不再是阻礙,而是提升產品品質的契機!