ExpressJS (TypeScript) – 使用 TypeScript 建立 Controllers:錯誤處理與型別安全
簡介
在以 Node.js + Express 為基礎的後端專案裡,控制器(Controller)是負責將 HTTP 請求與業務邏輯連結的核心模組。當專案規模逐漸擴大,若沒有統一的錯誤處理機制與嚴謹的型別定義,極易出現 未捕捉的例外、錯誤回傳不一致,甚至讓前端開發者在除錯時陷入泥沼。
引入 TypeScript 後,我們不僅能在編譯階段即捕獲大部分錯誤,還能藉由介面(Interface)與型別別名(Type Alias)為每一個 API 的輸入、輸出、以及錯誤資訊建立清晰的合約(Contract)。本篇文章將說明如何在 Express 的 Controller 中:
- 統一錯誤處理(全域錯誤中介軟體、自訂錯誤類別)
- 保證型別安全(Request/Response 型別、DTO、Result 型別)
從概念、實作到常見陷阱,提供完整且可直接套用的範例,幫助初學者到中階開發者快速上手。
核心概念
1️⃣ 為什麼要自訂錯誤類別?
純粹拋出 Error 雖然可以中斷程式執行,但缺乏結構化資訊(例如 HTTP status code、錯誤代碼、使用者可讀訊息)。自訂錯誤類別讓我們在 捕捉錯誤時 能夠:
- 依錯誤類型回傳正確的 HTTP status
- 把錯誤代碼(
errorCode)與前端約定的錯誤訊息對應 - 在日誌系統中提供更完整的堆疊資訊
範例 1:自訂基礎錯誤類別 AppError
// src/errors/AppError.ts
export class AppError extends Error {
// HTTP status code,預設 500
public readonly statusCode: number;
// 供前端辨識的錯誤代碼(可自行定義)
public readonly errorCode: string;
// 是否需要在回傳給前端時隱藏詳細堆疊
public readonly isOperational: boolean;
constructor(
message: string,
statusCode = 500,
errorCode = 'UNKNOWN_ERROR',
isOperational = true,
) {
super(message);
this.statusCode = statusCode;
this.errorCode = errorCode;
this.isOperational = isOperational;
// 必須保留正確的 prototype 鏈
Object.setPrototypeOf(this, new.target.prototype);
}
}
重點:
Object.setPrototypeOf能確保instanceof AppError在執行時正確判斷,避免因 TypeScript 編譯產生的 prototype 錯位。
2️⃣ 全域錯誤中介軟體(Error‑handling middleware)
Express 允許我們在最後一個 middleware 中統一處理所有錯誤。配合 AppError,可以把錯誤轉換成 JSON 格式回傳,且只把必要資訊暴露給前端。
範例 2:全域錯誤中介軟體
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/AppError';
export function errorHandler(
err: Error,
_req: Request,
res: Response,
// 必須保留 next 參數,才能被 Express 識別為錯誤中介軟體
_next: NextFunction,
) {
// 若是自訂的 AppError,直接使用其屬性
if (err instanceof AppError) {
const { statusCode, errorCode, message, isOperational } = err;
const responseBody = {
success: false,
errorCode,
// 只在開發環境或非 operational error 時回傳原始訊息
message: process.env.NODE_ENV !== 'production' && !isOperational
? message
: '系統發生錯誤,請稍後再試。',
};
return res.status(statusCode).json(responseBody);
}
// 未知錯誤:記錄日誌,回傳 500
console.error(err);
res.status(500).json({
success: false,
errorCode: 'INTERNAL_SERVER_ERROR',
message: '系統發生未知錯誤,請聯絡管理員。',
});
}
技巧:在
app.ts(或server.ts)中,最後 使用app.use(errorHandler);,確保所有路由錯誤都會被捕捉。
3️⃣ 請求與回應的型別安全(DTO)
在 TypeScript 中,我們常用 Data Transfer Object(DTO) 來描述 API 輸入/輸出結構。透過 class-validator、class-transformer,可以在控制器層直接驗證資料,同時保證型別正確。
範例 3:使用 DTO 驗證 POST /users 請求
// src/dto/CreateUserDto.ts
import { IsEmail, IsString, Length } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email!: string;
@IsString()
@Length(6, 20)
password!: string;
@IsString()
@Length(1, 30)
name!: string;
}
// src/middleware/validationMiddleware.ts
import { plainToInstance } from 'class-transformer';
import { validate, ValidationError } from 'class-validator';
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/AppError';
export function validationMiddleware<T>(type: new () => T) {
return async (req: Request, res: Response, next: NextFunction) => {
const dto = plainToInstance(type, req.body);
const errors = await validate(dto as unknown as object);
if (errors.length > 0) {
const messages = flattenValidationErrors(errors);
return next(
new AppError(messages.join(', '), 400, 'VALIDATION_ERROR')
);
}
// 把已驗證過的 DTO 放到 request 上,供後續 controller 使用
(req as any).validatedBody = dto;
next();
};
}
// 把 class-validator 的錯誤格式展平成字串陣列
function flattenValidationErrors(errors: ValidationError[]): string[] {
const result: string[] = [];
for (const err of errors) {
if (err.constraints) {
result.push(...Object.values(err.constraints));
}
if (err.children && err.children.length) {
result.push(...flattenValidationErrors(err.children));
}
}
return result;
}
說明:
validationMiddleware(CreateUserDto)會在路由執行前自動驗證req.body,若失敗則拋出AppError,交給全域錯誤處理器回傳 400。
4️⃣ 統一的回傳型別(Result 型別)
為了讓前端在收到回應時不必每次都判斷結構,我們可以定義一個 泛型 Result,所有成功或失敗的回傳都遵循同一個介面。
範例 4:Result 定義與使用
// src/types/Result.ts
export interface SuccessResult<T> {
success: true;
data: T;
}
export interface FailureResult {
success: false;
errorCode: string;
message: string;
}
export type Result<T> = SuccessResult<T> | FailureResult;
// src/controllers/UserController.ts
import { Request, Response, NextFunction } from 'express';
import { Result } from '../types/Result';
import { UserService } from '../services/UserService';
import { CreateUserDto } from '../dto/CreateUserDto';
import { AppError } from '../errors/AppError';
export class UserController {
static async register(
req: Request,
res: Response,
next: NextFunction,
) {
try {
// 已經在 validationMiddleware 中驗證過,直接斷言型別
const dto: CreateUserDto = (req as any).validatedBody;
const user = await UserService.createUser(dto);
const result: Result<typeof user> = {
success: true,
data: user,
};
res.status(201).json(result);
} catch (err) {
// 自己拋出的錯誤會被全域 errorHandler 捕捉
next(err);
}
}
}
關鍵:前端只需要檢查
response.success為true或false,就能知道是否成功,同時取得data或errorCode/message。
5️⃣ 在 Service 層拋出自訂錯誤
Controller 只負責 協調,真正的商業邏輯與錯誤判斷應放在 Service。這樣可以保持 Controller 的簡潔,且錯誤類型保持一致。
範例 5:UserService 中的錯誤拋出
// src/services/UserService.ts
import { CreateUserDto } from '../dto/CreateUserDto';
import { AppError } from '../errors/AppError';
import { UserModel } from '../models/UserModel';
export class UserService {
static async createUser(dto: CreateUserDto) {
// 檢查 email 是否已被註冊
const exists = await UserModel.findOne({ email: dto.email });
if (exists) {
throw new AppError('此 Email 已被註冊', 409, 'EMAIL_ALREADY_EXISTS');
}
// 密碼雜湊(此處僅示意)
const hashedPassword = await someHashFunction(dto.password);
const newUser = await UserModel.create({
email: dto.email,
password: hashedPassword,
name: dto.name,
});
// 回傳剔除敏感欄位的資料
const { password, ...safeUser } = newUser.toObject();
return safeUser;
}
}
提示:
AppError的statusCode與errorCode直接對應到 HTTP 回傳與前端錯誤代碼,讓前後端協議更清晰。
常見陷阱與最佳實踐
| 陷阱 | 為什麼會發生 | 解決方式/最佳實踐 |
|---|---|---|
忘記在 next(err) 前 return |
會導致程式繼續往下執行,產生兩次回傳或未預期的錯誤。 | 立即 return next(err);,或使用 throw 讓 async/await 捕獲。 |
在 catch 中直接 res.json |
失去全域錯誤中介軟體的統一處理,回傳格式不一致。 | 只 next(err),讓 errorHandler 統一回應。 |
自訂錯誤類別未繼承 Error |
instanceof 判斷失效,導致錯誤被視為未知。 |
確保 class MyError extends Error,並呼叫 Object.setPrototypeOf. |
| DTO 驗證失敗但仍繼續執行業務邏輯 | 可能因為忘記 return 或 next 沒傳錯誤。 |
在 validationMiddleware 中 return next(new AppError(...))。 |
| 在 Service 中直接回傳 Mongoose Document | 前端可能看到 _id、__v、password 等敏感欄位。 |
在 Service 層剔除或映射成 DTO,只回傳安全欄位。 |
| 錯誤訊息直接寫死在程式碼 | 不易維護,也不利於多語系或前端統一錯誤呈現。 | 使用 錯誤代碼(errorCode)+ 訊息映射檔,前端根據代碼顯示相應文字。 |
最佳實踐小結
- 所有錯誤必須拋出或傳遞
AppError,絕不在 Controller 內自行res.status(...).json。 - DTO + validation middleware 為入口層的第一道防線,保證
req.body的型別與格式。 - Result
為所有 API 的統一回傳格式,讓前端只需要判斷 success。 - Service 層保持純粹的業務邏輯,不涉及 Express 相關的
req/res,便於單元測試。 - 全域錯誤中介軟體 必須放在所有路由之後,且不應該在其中再次拋出錯誤(除非想讓 Node 直接 crash)。
實際應用場景
場景一:使用者註冊
- 路由:
POST /api/v1/users→validationMiddleware(CreateUserDto)→UserController.register - 流程:
- 請求先經過 DTO 驗證,若失敗回傳
VALIDATION_ERROR(400)。 - Service 內檢查 Email 重複,若重複拋出
EMAIL_ALREADY_EXISTS(409)。 - 成功建立後回傳
{ success: true, data: user }(201)。
- 請求先經過 DTO 驗證,若失敗回傳
- 前端:只要檢查
success,若false讀取errorCode轉成 UI 提示(例如「此 Email 已被註冊」)。
場景二:取得單筆資源(GET /users/:id)
// src/routes/userRoutes.ts
router.get(
'/:id',
asyncHandler(UserController.getById) // asyncHandler 為捕捉 async 錯誤的 wrapper
);
// src/controllers/UserController.ts
static async getById(req: Request, res: Response, next: NextFunction) {
try {
const user = await UserService.findById(req.params.id);
if (!user) {
throw new AppError('找不到使用者', 404, 'USER_NOT_FOUND');
}
const result: Result<typeof user> = { success: true, data: user };
res.json(result);
} catch (err) {
next(err);
}
}
- 錯誤情境:ID 格式錯誤或找不到資源,都會拋出
AppError,最終由errorHandler統一回傳{ success: false, errorCode, message }。
場景三:批次更新(PUT /orders)
對於較為複雜的批次操作,我們仍遵循相同模式:
- DTO 描述每筆更新的結構(陣列內的物件)。
- Service 內使用 transaction(若使用 MongoDB 4.0+)或其他機制,確保「全部成功或全部失敗」。
- 錯誤 仍以
AppError包裝,讓前端一次取得所有失敗原因(可在message中列出多筆失敗的詳情)。
總結
在 Express + TypeScript 的專案中,錯誤處理與型別安全 不是兩件孤立的事,而是相輔相成的設計原則。透過:
- 自訂
AppError建立結構化錯誤資訊; - 全域錯誤中介軟體 統一回傳格式;
- DTO + validation middleware 把入口層的資料驗證與型別保證前置化;
- Result
為所有 API 定義統一回傳結構; - Service 層負責業務與錯誤拋出,保持 Controller 的簡潔與可測試性;
我們可以在開發階段即捕獲大多數錯誤,並在上線後提供前端一致且易於處理的回應。這種做法不僅提升 程式碼可維護性,也讓 團隊協作 更加順暢,因為每個人都清楚錯誤的類型、回傳方式以及型別合約。
實務建議:在新專案啟動時即導入上述模板,並在 CI 流程加入 TypeScript 編譯與單元測試,確保每次提交都不會破壞錯誤處理與型別安全的基礎。未來若要擴充功能,只要遵循「DTO → Service → Controller → errorHandler」的流水線,就能保持系統的穩定與可預測性。祝開發順利!