ExpressJS (TypeScript) – 錯誤處理與例外管理
使用 express-async-errors
簡介
在 Node.js 與 Express 開發 API 時,最常碰到的問題之一就是 非同步 程式碼拋出的例外無法被全域錯誤中介軟體捕捉。傳統上,我們必須在每一個 async 路由處理函式裡手動加入 try…catch,或是把錯誤傳遞給 next(err)。這樣的寫法不僅冗長,也很容易遺漏,導致伺服器在發生例外時直接 Crash,使用者收到不友善的 500 錯誤。
express-async-errors 這個小套件正是為了解決這個痛點而誕生。它會自動把 async 函式裡拋出的例外轉成 Express 能辨識的錯誤,讓我們只需要撰寫一次 全域錯誤處理器,就能統一處理所有同步與非同步的錯誤。本文將以 TypeScript 為例,說明如何正確安裝、設定與使用 express-async-errors,並分享實務上常見的陷阱與最佳實踐。
核心概念
1. 為什麼需要 express-async-errors
在 Express 的設計裡,只有 同步 的例外會自動傳遞給錯誤中介軟體;而 非同步(例如 await、Promise)拋出的錯誤則會被視為未處理的例外,除非我們手動呼叫 next(err)。
app.get('/users/:id', async (req, res) => {
// 下面的錯誤不會被 Express 捕捉
const user = await UserModel.findById(req.params.id); // 若找不到會拋錯
res.json(user);
});
上例在找不到使用者時會直接觸發未捕捉的例外,導致程式中斷。
express-async-errors 會在底層把 async 函式包裝起來,讓任何拋出的錯誤自動送到 next(err),從而觸發全域錯誤處理器。
2. 安裝與基本設定
npm i express-async-errors
# 若使用 TypeScript,建議同時安裝型別定義
npm i -D @types/express-async-errors
在 入口檔案(通常是 src/index.ts)最上方 先匯入 此套件,僅需一次即可套用於整個應用程式:
// src/index.ts
import 'express-async-errors'; // <─ 必須在其他 import 之前
import express from 'express';
import routes from './routes';
import { errorHandler } from './middleware/errorHandler';
const app = express();
app.use(express.json());
app.use('/api', routes);
// 放在所有路由之後的全域錯誤處理器
app.use(errorHandler);
export default app;
⚠️ 重點:
import 'express-async-errors'必須在 任何路由 匯入之前執行,否則它無法正確攔截async函式。
3. 撰寫全域錯誤處理器
在 TypeScript 中,我們通常會自訂錯誤類別,並在錯誤處理器裡根據錯誤類型回傳適當的 HTTP 狀態碼與訊息。以下是一個簡潔且可擴充的範例:
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
// 自訂基礎錯誤類別
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
// 修正 prototype 鏈(在 TypeScript 中必要)
Object.setPrototypeOf(this, new.target.prototype);
}
}
// 其他常用錯誤類別
export class NotFoundError extends HttpError {
constructor(message = '資源未找到') {
super(404, message);
}
}
export class ValidationError extends HttpError {
constructor(message = '參數驗證失敗') {
super(400, message);
}
}
// 全域錯誤中介軟體
export const errorHandler = (
err: any,
_req: Request,
res: Response,
_next: NextFunction
) => {
// 若錯誤已經是 HttpError,直接使用其 status
const status = err instanceof HttpError ? err.status : 500;
const message =
status === 500
? '系統內部錯誤,請稍後再試' // 盡量不要洩漏細節
: err.message;
// 只在開發環境印出 stack trace
if (process.env.NODE_ENV !== 'production') {
console.error(err);
}
res.status(status).json({ error: message });
};
📝 小技巧:在
errorHandler裡 不要 呼叫next(err),否則會產生無限迴圈。只要回傳回應即可。
4. 範例:簡易 CRUD API 使用 express-async-errors
以下示範三個常見情境,說明如何在路由、服務層與驗證階段利用 express-async-errors 免除 try…catch:
4.1. 基本的 GET 路由
// src/routes/user.ts
import { Router } from 'express';
import { UserModel } from '../models/User';
import { NotFoundError } from '../middleware/errorHandler';
const router = Router();
router.get('/:id', async (req, res) => {
const user = await UserModel.findById(req.params.id);
if (!user) throw new NotFoundError('找不到指定的使用者');
res.json(user);
});
export default router;
不需要 try…catch,因為 express-async-errors 會自動把 throw 轉成錯誤傳遞。
4.2. 建立資源時的參數驗證
// src/routes/user.ts (續)
import { ValidationError } from '../middleware/errorHandler';
import { body, validationResult } from 'express-validator';
router.post(
'/',
// 使用 express-validator 做同步驗證
body('email').isEmail(),
body('name').isLength({ min: 2 }),
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
// 直接拋出自訂錯誤即可
throw new ValidationError('請檢查輸入欄位');
}
const newUser = await UserModel.create(req.body);
res.status(201).json(newUser);
}
);
4.3. 服務層拋出自訂錯誤
// src/services/authService.ts
import { HttpError } from '../middleware/errorHandler';
import bcrypt from 'bcrypt';
import { UserModel } from '../models/User';
export const login = async (email: string, password: string) => {
const user = await UserModel.findOne({ email });
if (!user) {
throw new HttpError(401, '帳號或密碼錯誤');
}
const match = await bcrypt.compare(password, user.passwordHash);
if (!match) {
throw new HttpError(401, '帳號或密碼錯誤');
}
// 產生 JWT(省略實作)
return { token: 'jwt-token' };
};
在路由裡直接呼叫服務,錯誤會一層層冒泡到全域錯誤處理器:
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const result = await login(email, password);
res.json(result);
});
5. TypeScript 型別支援
express-async-errors 本身不需要額外的型別宣告,只要在 tsconfig.json 中啟用 esModuleInterop 即可順利編譯。若想要讓錯誤物件在 IDE 中更具體,可自行擴充 Error 介面:
// src/types/custom-error.d.ts
declare module 'http' {
interface IncomingMessage {
// 讓 Request 物件可以直接掛載自訂屬性(如 userId)
userId?: string;
}
}
這樣在錯誤處理器裡就能取得額外資訊,提升除錯效率。
常見陷阱與最佳實踐
| 陷阱 | 可能的結果 | 解決方式 |
|---|---|---|
忘記在入口檔案最上方匯入 express-async-errors |
非同步錯誤仍會直接 Crash | 確認 import 'express-async-errors' 為第一行 |
在錯誤處理器裡再次呼叫 next(err) |
產生無限迴圈,最終導致 Stack Overflow | 直接 res.status(...).json(...) 即可 |
拋出普通的 Error,卻忘記設定 status |
客戶端會收到 500,無法分辨錯誤類型 | 使用自訂的 HttpError 或在拋出前手動設定 status |
在開發環境直接回傳 err.stack |
資訊洩漏,安全風險 | 只在 process.env.NODE_ENV !== 'production' 時印出 log,回傳給前端的訊息保持簡潔 |
混用 express-async-errors 與自行包裝的 asyncHandler |
重複包裝,效能略微下降 | 只選擇其一,若已使用 express-async-errors,不必再自行包裝 |
最佳實踐
- 全域錯誤處理器放在最後:所有路由與中介軟體之後才掛載
app.use(errorHandler)。 - 自訂錯誤類別:讓每個錯誤都帶有
status,便於統一回傳 HTTP 狀態碼。 - 分層拋錯:服務層(service)拋出錯誤,控制層(controller)只負責回傳結果,保持職責單一。
- 不在錯誤處理器內再次拋錯:若需要額外處理,直接
res回應或寫入日誌。 - 在測試環境也要啟用:單元測試時,確保
express-async-errors已被載入,否則非同步錯誤會直接失敗測試。
實際應用場景
1. 大型 RESTful API
在微服務或大型單體應用中,往往有上百條路由。若每條路由都必須寫 try…catch,代碼量會膨脹且容易遺漏。使用 express-async-errors 能讓開發者把注意力集中在 業務邏輯,而非錯誤捕捉的樣板程式。
2. 多層驗證與授權
例如,先在中介軟體做 JWT 驗證,接著在服務層做資料庫查詢,最後在控制層回傳結果。任何層級發生的例外(Token 過期、DB 連線失敗、業務規則錯誤)都會自動傳遞到全域錯誤處理器,讓前端只收到一致的錯誤格式。
3. 團隊協作與程式碼審查
統一的錯誤處理機制讓程式碼審查變得更簡單:審查者只需要關注 錯誤類別的設計、錯誤訊息的語意,而不必檢查每個 async 函式是否正確使用 try…catch。
總結
- express-async-errors 為 Express 的非同步錯誤捕捉提供了「一勞永逸」的方案,只要在入口檔案先匯入,所有
async路由的例外都會自動傳遞至全域錯誤中介軟體。 - 結合 TypeScript 的自訂錯誤類別,我們可以在錯誤處理器裡根據錯誤型別回傳正確的 HTTP 狀態碼與友善訊息,提升 API 的可讀性與維護性。
- 透過 最佳實踐(如集中錯誤處理、避免重複包裝、只在開發環境印出 Stack)與 常見陷阱 的警示,開發者可以快速上手且不會踩到常見的坑。
- 在實務上,這套機制特別適合 大型 RESTful API、分層驗證/授權 以及 多人協作的專案,讓錯誤管理變得一致且可預測。
只要掌握上述概念與範例,您就能在 Express + TypeScript 專案中,以最少的程式碼實現 健全、可維護的錯誤處理。祝開發順利,錯誤不再是阻礙!