ExpressJS (TypeScript) – 統一的 Error Middleware 結構
簡介
在大型的 Node.js/Express 專案中,錯誤處理往往是最容易被忽略、卻最關鍵的部分。若每個路由、服務或第三方套件都自行拋出錯誤,而沒有統一的處理機制,伺服器很容易回傳不一致的錯誤訊息,甚至導致程式崩潰、資源洩漏或安全漏洞。
使用 TypeScript 能讓我們在編譯階段就捕捉大部分錯誤型別,但運行時仍會出現例外(Exception)或 Promise 拒絕(rejection)。因此,建立一套 統一的 error middleware,不僅能保證 API 回傳格式的一致性,還能集中記錄、分類與回傳適當的 HTTP 狀態碼,提升除錯效率與使用者體驗。
本篇文章將說明如何在 Express + TypeScript 專案中設計、實作與最佳化錯誤中介層,讓你的應用程式在面對各種錯誤時都能保持「乾淨、可預測」的行為。
核心概念
1️⃣ 為何要統一 Error Middleware
| 常見問題 | 若沒有統一機制的結果 |
|---|---|
| 錯誤訊息格式不一致 | 前端需要寫多套解析程式,維護成本升高 |
| 錯誤被吞掉或未回傳 | 使用者得到 200 OK 但實際失敗 |
| 未記錄錯誤 | 生產環境難以追蹤問題根源 |
| 敏感資訊外洩 | 堆疊資訊直接回傳給使用者,可能暴露內部實作 |
結論:一個 集中式、型別安全 的 error middleware 能一次解決上述問題。
2️⃣ Error Middleware 的基本結構
在 Express 中,錯誤中介層的簽名必須為 (err, req, res, next),且必須放在所有路由之後。使用 TypeScript 時,我們可以利用 Error 的子類別來攜帶額外資訊(如 HTTP 狀態碼、錯誤代碼)。
import { Request, Response, NextFunction } from 'express';
/** 基礎的自訂錯誤類別 */
export class HttpError extends Error {
public status: number;
public code?: string; // 可自行定義錯誤代碼,例如 'USER_NOT_FOUND'
constructor(status: number, message: string, code?: string) {
super(message);
this.status = status;
this.code = code;
// 必須手動設定原型鏈,避免 instanceof 判斷失效
Object.setPrototypeOf(this, new.target.prototype);
}
}
/** 統一的 error middleware */
export const errorHandler = (
err: unknown,
_req: Request,
res: Response,
_next: NextFunction,
) => {
// 1️⃣ 先判斷是否為自訂的 HttpError
if (err instanceof HttpError) {
return res.status(err.status).json({
success: false,
error: {
message: err.message,
code: err.code ?? 'UNKNOWN_ERROR',
},
});
}
// 2️⃣ 再處理其他已知錯誤類型(例如 ValidationError)
// ...(此處可自行擴充)
// 3️⃣ 未知錯誤 => 內部伺服器錯誤
console.error(err); // **務必記錄**,避免錯誤沉默
return res.status(500).json({
success: false,
error: {
message: '系統發生未預期的錯誤,請稍後再試。',
code: 'INTERNAL_SERVER_ERROR',
},
});
};
重點:
- 使用
instanceof判斷自訂錯誤類別,確保型別安全。- 所有錯誤都要走同一條回傳路徑,前端只需要處理一次
success: false的結構。
3️⃣ 在路由、服務層拋出錯誤
範例 1:簡單的參數驗證錯誤
import { Request, Response, NextFunction } from 'express';
import { HttpError } from '../middlewares/errorHandler';
export const getUser = async (req: Request, res: Response, next: NextFunction) => {
const { id } = req.params;
if (!/^\d+$/.test(id)) {
// 直接拋出自訂錯誤,交給 errorHandler 處理
return next(new HttpError(400, '使用者 ID 必須為正整數', 'INVALID_USER_ID'));
}
// 正常流程...
};
範例 2:服務層的業務例外
// src/services/userService.ts
import { HttpError } from '../middlewares/errorHandler';
import { UserModel } from '../models/User';
export const findUserByEmail = async (email: string) => {
const user = await UserModel.findOne({ email });
if (!user) {
// 這裡拋出錯誤,讓呼叫端不必自行判斷 null
throw new HttpError(404, `找不到 email 為 ${email} 的使用者`, 'USER_NOT_FOUND');
}
return user;
};
範例 3:捕捉非同步錯誤的簡潔寫法
// src/utils/asyncWrapper.ts
import { Request, Response, NextFunction } from 'express';
/**
* 將 async route handler 包裝成 (req, res, next) => void
* 讓所有拋出的例外自動傳給 next()
*/
export const asyncWrapper = (fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) => {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next);
};
};
// 使用方式
import { asyncWrapper } from '../utils/asyncWrapper';
import { findUserByEmail } from '../services/userService';
router.get(
'/by-email/:email',
asyncWrapper(async (req, res) => {
const user = await findUserByEmail(req.params.email);
res.json({ success: true, data: user });
})
);
4️⃣ 統一回傳格式
在多數團隊中,API 回傳規範 常採用以下結構:
{
"success": true | false,
"data": {...} | null,
"error": {
"message": "錯誤說明",
"code": "錯誤代碼",
"details": {...} // 可選,提供額外資訊
}
}
因此,我們的 error middleware 會固定回傳 success: false 並放入 error 物件。只要在所有路由的成功回傳中遵守 success: true、data 欄位的慣例,前端就能 一次性解析 所有回應。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 最佳實踐 |
|---|---|---|
忘記 next(err) |
直接 throw 於非 async 函式會被 Node 捕捉,但不會自動傳給 middleware。 |
使用 asyncWrapper 或在同步路由中顯式 return next(err)。 |
| 在 error middleware 中再次拋錯 | 會造成無限迴圈或 Express 無法回應。 | 永遠在最後一層回傳 res.status(...).json(...),不要再呼叫 next(err)。 |
| 回傳完整 stack trace | 會洩漏內部實作,危害安全。 | 只在 開發環境(process.env.NODE_ENV === 'development')印出堆疊,正式環境僅回傳通用訊息。 |
| 錯誤類別不統一 | 不同模組拋出不同型別,難以集中處理。 | 建立 基礎錯誤類別(如 HttpError),所有自訂錯誤都繼承它。 |
| 忘記把 error middleware 放在最底 | 若放在路由之前,錯誤不會被捕捉。 | 在 app.use(...routes) 之後 最後 app.use(errorHandler)。 |
額外技巧
- 使用
class-validator+typeorm時,可把驗證錯誤轉成HttpError(400, ...)。 - Log 收斂:使用
winston或pino把錯誤寫入檔案或外部監控平台(如 Sentry)。 - 自訂錯誤代碼:在錯誤物件中加入
code,方便前端對應 UI 行為(例如顯示登入失敗 vs. 權限不足)。
實際應用場景
1️⃣ 多租戶 SaaS 平台
在 SaaS 系統中,同一套 API 會服務多個客戶(tenant)。若某個租戶的資料庫發生連線失敗,若未統一處理,可能會把 資料庫錯誤訊息(包含密碼)直接回傳給前端。使用統一的 error middleware,我們可以:
- 把所有 DB 相關錯誤包成
HttpError(503, '服務暫時無法使用', 'DB_CONNECTION_ERROR')。 - 在 middleware 中根據
process.env.NODE_ENV決定是否把堆疊寫入日誌。 - 前端只會收到
code: 'DB_CONNECTION_ERROR',再根據代碼顯示「系統維護中」的訊息。
2️⃣ 需要驗證 JWT 的保護路由
import jwt from 'jsonwebtoken';
import { HttpError } from '../middlewares/errorHandler';
export const authGuard = (req: Request, _res: Response, next: NextFunction) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return next(new HttpError(401, '未提供授權憑證', 'TOKEN_MISSING'));
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!);
// 把使用者資訊掛在 req 上,供後續使用
(req as any).user = payload;
next();
} catch (e) {
return next(new HttpError(401, '授權憑證無效或已過期', 'TOKEN_INVALID'));
}
};
所有需要驗證的路由只要 app.use(authGuard),錯誤訊息會由 error middleware 統一回傳,前端不必自行檢查 try/catch。
3️⃣ 大型微服務間的錯誤轉譯
微服務 A 呼叫微服務 B,B 回傳 404 時,我們在 A 中捕捉到 AxiosError,再拋成 HttpError(404, '資源不存在', 'RESOURCE_NOT_FOUND') 給前端。這樣即使底層呼叫的服務改變,外部 API 的錯誤介面保持不變。
總結
- 統一的 error middleware 是 Express + TypeScript 專案的基石,能確保錯誤回傳格式、日誌紀錄與安全性的一致性。
- 透過 自訂
HttpError類別、asyncWrapper 與 型別安全的判斷,我們可以在路由、服務層、第三方呼叫中自由拋錯,卻不會讓錯誤「跑到」未知的地方。 - 常見的坑(忘記
next(err)、回傳堆疊、錯誤類別不統一)只要遵循 最佳實踐(集中處理、區分開發/正式環境、使用日誌)即可輕鬆避免。 - 真正的價值在於 實務應用:不論是 SaaS 多租戶、JWT 驗證、或微服務間的錯誤轉譯,都能藉由統一的錯誤機制讓前端開發者只需要一次性處理
success: false的回應。
把這套 「錯誤 → 捕捉 → 統一回傳」 的流程寫入你的專案腳手架,未來的功能擴充、除錯與維護都會變得更順手、更安全。祝開發順利,錯誤不再是障礙,而是讓系統更穩固的助力!