ExpressJS (TypeScript) – 錯誤處理與例外管理
主題:Error Object 設計(自訂 Error Class)
簡介
在任何 Web API 中,錯誤的捕捉與回傳都是使用者體驗與系統穩定性的關鍵。Express 內建 next(err) 機制可以把例外交給全域錯誤處理器,但如果直接拋出原生的 Error,往往只能得到模糊的訊息,對前端或日誌系統都不友好。
透過 自訂 Error Class,我們可以在錯誤物件裡加入 HTTP 狀態碼、錯誤代碼、可序列化的資料等欄位,讓錯誤在傳遞、記錄、以及回傳給客戶端時都保持一致且可讀。本文將以 Express + TypeScript 為例,說明如何設計、使用與最佳化自訂錯誤類別,幫助初學者快速上手,同時提供中階開發者在大型專案中統一錯誤格式的實務技巧。
核心概念
1️⃣ 為什麼要自訂 Error Class?
- 統一介面:所有錯誤都遵循同一個結構(
status,code,message,data),中間件只需要檢查這些屬性即可。 - 可擴充:未來若要加入
errorId、traceId或客製化的logLevel,只要在基底類別上新增即可,所有子類別自動繼承。 - 型別安全:使用 TypeScript 時,透過介面或抽象類別能在編譯期就捕捉錯誤屬性遺漏的問題。
2️⃣ 基礎抽象類別 AppError
// src/errors/AppError.ts
export abstract class AppError extends Error {
/** HTTP 狀態碼,預設 500 */
public readonly status: number;
/** 業務層自訂錯誤代碼,方便前端對照 */
public readonly code: string;
/** 可選的額外資料,會在回傳 JSON 時一起帶出 */
public readonly data?: unknown;
constructor(message: string, status = 500, code = 'UNKNOWN_ERROR', data?: unknown) {
super(message);
this.status = status;
this.code = code;
this.data = data;
// 讓 instanceof 判斷正確
Object.setPrototypeOf(this, new.target.prototype);
// 捕捉 stack trace(Node.js 專用)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
/** 轉換成 API 回傳格式 */
public toResponse() {
return {
error: {
code: this.code,
message: this.message,
...(this.data && { data: this.data })
}
};
}
}
abstract:不允許直接實例化,只能被繼承。Object.setPrototypeOf:解決 TypeScript 在繼承內建Error時instanceof判斷失效的問題。toResponse():將錯誤物件轉成前端友好的 JSON 結構,所有錯誤類別皆可直接呼叫。
3️⃣ 常見的子類別
3.1 ValidationError(驗證失敗)
// src/errors/ValidationError.ts
import { AppError } from './AppError';
export class ValidationError extends AppError {
constructor(message: string, data?: Record<string, any>) {
super(message, 400, 'VALIDATION_ERROR', data);
}
}
3.2 NotFoundError(資源找不到)
// src/errors/NotFoundError.ts
import { AppError } from './AppError';
export class NotFoundError extends AppError {
constructor(resource: string, id: string | number) {
super(`${resource} (${id}) not found`, 404, 'NOT_FOUND');
}
}
3.3 UnauthorizedError(未授權)
// src/errors/UnauthorizedError.ts
import { AppError } from './AppError';
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'UNAUTHORIZED');
}
}
4️⃣ 在路由中拋出自訂錯誤
// src/routes/user.ts
import { Router, Request, Response, NextFunction } from 'express';
import { ValidationError, NotFoundError } from '../errors';
import { getUserById } from '../services/userService';
const router = Router();
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
const { id } = req.params;
if (!/^\d+$/.test(id)) {
// 直接拋出自訂錯誤,交給全域錯誤處理器
return next(new ValidationError('User ID must be a numeric string', { id }));
}
try {
const user = await getUserById(Number(id));
if (!user) {
throw new NotFoundError('User', id);
}
res.json({ data: user });
} catch (err) {
next(err); // 交給下方的 error‑handler
}
});
export default router;
5️⃣ 全域錯誤處理 Middleware
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/AppError';
export function errorHandler(
err: unknown,
_req: Request,
res: Response,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_next: NextFunction
) {
// 若是我們自訂的錯誤類別,直接使用其屬性
if (err instanceof AppError) {
const { status, toResponse } = err;
return res.status(status).json(toResponse());
}
// 其他非預期錯誤,僅回傳 500,並在伺服器端記錄
console.error(err);
return res.status(500).json({
error: {
code: 'INTERNAL_SERVER_ERROR',
message: '系統發生未預期的錯誤,請稍後再試'
}
});
}
在 app.ts 中最後掛上:
import express from 'express';
import userRouter from './routes/user';
import { errorHandler } from './middleware/errorHandler';
const app = express();
app.use(express.json());
app.use('/users', userRouter);
// 必須放在所有路由之後
app.use(errorHandler);
export default app;
常見陷阱與最佳實踐
| 陷阱 | 現象 | 解決方式 |
|---|---|---|
忘記 Object.setPrototypeOf |
instanceof ValidationError 為 false,導致錯誤無法被正確捕捉 |
在基底類別建構子中加入 Object.setPrototypeOf(this, new.target.prototype); |
直接回傳原生 Error |
前端只能收到 message,缺少狀態碼與代碼 |
只拋出自訂的 AppError 子類別,或在全域錯誤處理器中將原生錯誤包裝成 AppError |
| 錯誤資訊過度暴露 | 生產環境回傳 stack trace、資料庫錯誤訊息等 | 在 errorHandler 中根據 process.env.NODE_ENV 決定回傳內容,僅保留 code & message |
未設定 status |
預設回傳 200,導致客戶端認為請求成功 | 基底類別預設 500,子類別明確指定正確的 HTTP 狀態碼 |
忘記 next(err) |
錯誤被 swallow,請求卡住或回傳 200 空白 | 在 async route 中使用 try/catch 並 next(err),或直接回傳 Promise.reject(err) |
最佳實踐
- 所有錯誤都繼承自
AppError:即使是第三方套件拋出的錯誤,也可在全域處理器中包裝一次。 - 錯誤代碼 (
code) 使用全大寫、底線分隔,方便前端列舉enum。 - 將業務相關資訊放在
data,例如驗證失敗的欄位列表、分頁參數錯誤等。 - 在測試環境,可利用
toResponse()直接驗證回傳結構,而不必跑完整的 HTTP 請求。 - 加入唯一的
errorId(UUID)於每筆錯誤,便於日誌追蹤與客戶支援。
實際應用場景
場景一:RESTful API 的統一錯誤回傳
在大型電商平台中,前端需要根據錯誤代碼顯示不同的 UI(例如 401 需要跳轉登入、403 顯示權限不足、400 顯示表單驗證錯誤)。透過自訂錯誤類別,後端只要拋出對應的錯誤,前端即可透過 error.code 做一致的處理。
場景二:微服務間的錯誤傳遞
假設有 order-service 呼叫 inventory-service,若庫存不足,inventory-service 會拋出 ValidationError('Insufficient stock', { productId, requested, available })。在 order-service 收到錯誤後,只要把同樣的 AppError 重新拋出或包裝,就能保留原始的 code 與 data,讓最終的 API 使用者得到完整資訊。
場景三:集中式日誌與監控
使用 winston 或 pino 實作日誌時,只要在 errorHandler 中檢查 instanceof AppError,即可將 status, code, message, data 與 stack 結構化寫入 Elasticsearch 或 Loki,方便後續的搜尋與告警。
總結
- 自訂 Error Class 為 Express + TypeScript 專案提供了「統一、可擴充、型別安全」的錯誤模型。
- 透過抽象基底
AppError,我們只需在子類別裡定義 HTTP 狀態碼、業務代碼、可選資料,即可在全域錯誤處理器中一次搞定回傳與日誌。 - 注意 prototype、status 預設值、以及 環境別的錯誤資訊,可避免常見陷阱。
- 在實務上,這套機制不僅讓前端 UI 能快速對錯誤做出回應,也讓微服務間的錯誤傳遞與集中式監控變得更簡潔。
掌握了自訂錯誤類別的設計與使用,你的 Express API 將會變得更可靠、易維護,也更能符合企業級開發的品質要求。祝開發順利!