ExpressJS (TypeScript) – Middleware 概念與使用
主題:錯誤處理 Middleware
簡介
在 Express 中,Middleware 是請求/回應生命週期的核心組件,負責攔截、加工或終止 HTTP 請求。雖然大多數教學會先講解驗證、日誌或路由分派,但 錯誤處理 Middleware 才是真正保證服務穩定、使用者體驗良好的關鍵。
- 若程式碼在任一階段拋出例外而未被捕捉,整個 Node.js 進程可能直接崩潰,造成服務中斷。
- 透過統一的錯誤處理 Middleware,我們可以把錯誤轉成 一致的 HTTP 回應(例如 JSON 錯誤資訊),同時記錄必要的除錯資訊。
本篇文章將以 TypeScript 為開發語言,深入說明錯誤處理 Middleware 的原理、實作方式、常見陷阱與最佳實踐,並提供多個可直接套用的範例,讓你在實務專案中快速上手。
核心概念
1. 錯誤處理 Middleware 的簽名
普通 Middleware 的函式簽名為 (req, res, next) => {},而 錯誤處理 Middleware 必須多一個 err 參數:
import { Request, Response, NextFunction } from 'express';
function errorHandler(err: any, req: Request, res: Response, next: NextFunction) {
// 處理錯誤
}
只要 next(err) 被呼叫,或是同步程式碼拋出例外,Express 會自動把控制權交給最近的錯誤處理 Middleware。
重點:錯誤處理 Middleware 必須放在所有路由與普通 Middleware 之後,否則不會被觸發。
2. 何時使用 next(err)
- 同步程式碼:直接
throw new Error('...'),Express 會捕捉並傳遞給錯誤處理 Middleware。 - 非同步程式碼(Promise / async/await):需要手動
catch後next(err),或是直接在async函式中拋出,Express 會自動捕獲(自 v5 起;v4 需要自行包裝)。
// 同步範例
app.get('/sync', (req, res, next) => {
if (!req.query.id) {
// 直接拋出例外,Express 會自動轉成錯誤
throw new Error('Missing id parameter');
}
res.send('OK');
});
// 非同步範例(async/await)
app.get('/async', async (req, res, next) => {
try {
const data = await someAsyncOperation();
res.json(data);
} catch (e) {
// 必須呼叫 next(e) 才會走錯誤 Middleware
next(e);
}
});
3. 統一錯誤格式
在大型專案中,前端往往期待固定格式的錯誤回應,例如:
{
"status": 400,
"code": "INVALID_PARAMETER",
"message": "Missing required field: id",
"details": null
}
透過錯誤處理 Middleware,我們可以將所有例外統一轉換成上述結構。
interface ApiError {
status: number; // HTTP 狀態碼
code: string; // 自訂錯誤代碼
message: string; // 使用者可讀訊息
details?: any; // 進階除錯資訊(僅在開發環境回傳)
}
4. 建立自訂 Error 類別
為了在 next(err) 時攜帶更多資訊,建議自訂錯誤類別:
// src/errors/HttpError.ts
export class HttpError extends Error {
public status: number;
public code: string;
public details?: any;
constructor(status: number, code: string, message: string, details?: any) {
super(message);
this.status = status;
this.code = code;
this.details = details;
// 保留正確的 prototype 鏈(在 TypeScript 中必要)
Object.setPrototypeOf(this, new.target.prototype);
}
}
在路由中直接拋出:
import { HttpError } from './errors/HttpError';
app.get('/user/:id', (req, res, next) => {
const user = findUser(req.params.id);
if (!user) {
// 交給錯誤 Middleware 處理
return next(new HttpError(404, 'USER_NOT_FOUND', `User ${req.params.id} not found`));
}
res.json(user);
});
5. 完整錯誤處理 Middleware 範例
以下是一個 可直接掛載 的錯誤處理 Middleware,支援自訂錯誤、未知錯誤與開發/正式環境的差異:
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { HttpError } from '../errors/HttpError';
export function errorHandler(
err: any,
_req: Request,
res: Response,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_next: NextFunction
) {
// 1️⃣ 判斷是否為我們自訂的 HttpError
const isHttpError = err instanceof HttpError;
// 2️⃣ 設定回傳的 HTTP 狀態碼(預設 500)
const status = isHttpError ? err.status : 500;
// 3️⃣ 組成統一的錯誤結構
const responseBody = {
status,
code: isHttpError ? err.code : 'INTERNAL_SERVER_ERROR',
message: isHttpError ? err.message : '系統發生未預期的錯誤',
// 只在開發環境回傳 stack,避免資訊外洩
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
// 若有額外細節資料也一起回傳
...(isHttpError && err.details ? { details: err.details } : {})
};
// 4️⃣ 記錄錯誤(可自行接入 Winston、Pino 等 logger)
console.error(`[${new Date().toISOString()}]`, err);
// 5️⃣ 回傳 JSON 給前端
res.status(status).json(responseBody);
}
在 app.ts 中掛載:
import express from 'express';
import { errorHandler } from './middleware/errorHandler';
const app = express();
// ... 其他 middleware / routes
// 最後掛載錯誤處理 middleware
app.use(errorHandler);
export default app;
程式碼範例(實用 5 篇)
範例 1:捕捉未處理的 Promise 拒絕
// src/utils/asyncWrapper.ts
import { Request, Response, NextFunction } from 'express';
/**
* 包裝 async route handler,使其自動呼叫 next(err)
*/
export const asyncWrapper = (fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) =>
(req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next);
};
使用方式:
import { asyncWrapper } from './utils/asyncWrapper';
app.get(
'/posts',
asyncWrapper(async (req, res) => {
const posts = await PostModel.find(); // 若此處拋錯會自動交給 errorHandler
res.json(posts);
})
);
範例 2:驗證失敗時拋出 400 錯誤
import { HttpError } from './errors/HttpError';
import { body, validationResult } from 'express-validator';
app.post(
'/register',
[
body('email').isEmail().withMessage('Email 格式不正確'),
body('password').isLength({ min: 6 }).withMessage('密碼至少 6 個字元')
],
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
// 把驗證錯誤集合轉成自訂錯誤
return next(
new HttpError(
400,
'VALIDATION_ERROR',
'請求參數驗證失敗',
errors.array()
)
);
}
// 正常註冊流程...
res.json({ message: '註冊成功' });
}
);
範例 3:全域捕捉未處理例外(process)
process.on('unhandledRejection', (reason: any) => {
console.error('未處理的 Promise 拒絕:', reason);
// 可選擇寫入 log、發送警報或 graceful shutdown
});
process.on('uncaughtException', (err: Error) => {
console.error('未捕捉的例外:', err);
// 建議在此處關閉 server,避免不一致狀態
process.exit(1);
});
範例 4:分層錯誤處理(路由層 + 全域層)
// routes/user.ts
import { Router } from 'express';
import { HttpError } from '../errors/HttpError';
import { asyncWrapper } from '../utils/asyncWrapper';
const router = Router();
router.get(
'/:id',
asyncWrapper(async (req, res, next) => {
const user = await UserService.getById(req.params.id);
if (!user) {
// 只在此路由層拋出 404,讓全域 errorHandler 統一回應
throw new HttpError(404, 'USER_NOT_FOUND', `找不到 ID 為 ${req.params.id} 的使用者`);
}
res.json(user);
})
);
export default router;
在 app.ts 中:
import userRouter from './routes/user';
app.use('/users', userRouter);
// 其他路由...
app.use(errorHandler); // 全域錯誤處理
範例 5:自訂錯誤日誌(結合 Winston)
// src/logger.ts
import winston from 'winston';
export const logger = winston.createLogger({
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [new winston.transports.Console()]
});
在 errorHandler 中使用:
import { logger } from '../logger';
export function errorHandler(err: any, _req: Request, res: Response, _next: NextFunction) {
const status = err instanceof HttpError ? err.status : 500;
logger.error('API Error', {
message: err.message,
status,
stack: err.stack,
// 其他自訂欄位
});
// 其餘回應同前述範例
}
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
忘記在非同步路由呼叫 next(err) |
async 函式拋出錯誤卻未被捕獲,導致請求懸掛。 |
使用 asyncWrapper 或在每個 catch 中 next(err)。 |
| 錯誤處理 Middleware 放在路由之前 | Express 找不到可用的錯誤處理程式,錯誤直接傳到 Node.js。 | 確保 app.use(errorHandler) 位於所有路由之最後。 |
| 回傳錯誤資訊過於詳細 | 在正式環境將 stack、SQL 語句等敏感資訊洩漏給外部。 |
只在 process.env.NODE_ENV === 'development' 時回傳除錯資訊。 |
未處理 Promise 拒絕 |
Node.js 會印出警告,甚至在未捕獲時退出。 | 全域監聽 unhandledRejection,或使用 asyncWrapper。 |
自訂 Error 類別未正確繼承 Error |
instanceof 判斷失敗,導致錯誤被當成 500 處理。 |
在建構子內使用 Object.setPrototypeOf(this, new.target.prototype);。 |
最佳實踐
- 統一錯誤格式:前端只要依賴一套結構,就能簡化錯誤 UI。
- 分層處理:路由層只拋出自訂錯誤,全域層負責日誌、回應、隱私控制。
- 使用型別:在 TypeScript 中定義
HttpError、ApiError介面,讓 IDE 能提示正確屬性。 - 記錄完整堆疊:使用 Winston、Pino 或 Bunyan 記錄錯誤,便於日後追蹤。
- Graceful Shutdown:遇到
uncaughtException時,先關閉 server、完成 pending 請求,再退出。
實際應用場景
- RESTful API:所有 CRUD 操作若發生驗證失敗、資源不存在或資料庫錯誤,都透過錯誤 Middleware 統一回傳 JSON,前端只要根據
code處理 UI。 - 第三方服務整合:調用外部 API 時,若回傳非 2xx 狀態,拋出自訂
HttpError(502, 'EXTERNAL_SERVICE_ERROR', ...),讓使用者知道是外部系統問題。 - 多租戶 SaaS 平台:不同租戶可能有不同的錯誤代碼映射,錯誤 Middleware 可根據
req.tenantId加上額外details,供監控平台做統計。 - 微服務間通訊:在 Express 作為 API Gateway 時,錯誤 Middleware 會把下游服務的錯誤重新包裝,避免內部錯誤直接洩漏。
總結
錯誤處理 Middleware 是 Express 應用的安全防線與使用者體驗保證。透過 統一的錯誤結構、自訂 Error 類別、asyncWrapper 等技巧,我們可以:
- 避免未捕獲例外導致服務崩潰
- 提供前端一致、易於解析的錯誤回應
- 在開發與正式環境間安全地切換除錯資訊
- 將錯誤日誌與監控集中管理
掌握上述概念後,你的 TypeScript + Express 專案將更加 穩健、可維護,也能在面對複雜業務需求時,保持良好的錯誤治理。祝開發順利,快把這套錯誤處理機制寫進你的下一個專案吧!