本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 使用 TypeScript 建立 Controllers:錯誤處理與型別安全


簡介

在以 Node.js + Express 為基礎的後端專案裡,控制器(Controller)是負責將 HTTP 請求與業務邏輯連結的核心模組。當專案規模逐漸擴大,若沒有統一的錯誤處理機制與嚴謹的型別定義,極易出現 未捕捉的例外、錯誤回傳不一致,甚至讓前端開發者在除錯時陷入泥沼。

引入 TypeScript 後,我們不僅能在編譯階段即捕獲大部分錯誤,還能藉由介面(Interface)與型別別名(Type Alias)為每一個 API 的輸入、輸出、以及錯誤資訊建立清晰的合約(Contract)。本篇文章將說明如何在 Express 的 Controller 中:

  1. 統一錯誤處理(全域錯誤中介軟體、自訂錯誤類別)
  2. 保證型別安全(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-validatorclass-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.successtruefalse,就能知道是否成功,同時取得 dataerrorCode/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;
  }
}

提示AppErrorstatusCodeerrorCode 直接對應到 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 驗證失敗但仍繼續執行業務邏輯 可能因為忘記 returnnext 沒傳錯誤。 在 validationMiddleware 中 return next(new AppError(...))
在 Service 中直接回傳 Mongoose Document 前端可能看到 _id__vpassword 等敏感欄位。 在 Service 層剔除或映射成 DTO,只回傳安全欄位。
錯誤訊息直接寫死在程式碼 不易維護,也不利於多語系或前端統一錯誤呈現。 使用 錯誤代碼errorCode)+ 訊息映射檔,前端根據代碼顯示相應文字。

最佳實踐小結

  1. 所有錯誤必須拋出或傳遞 AppError,絕不在 Controller 內自行 res.status(...).json
  2. DTO + validation middleware 為入口層的第一道防線,保證 req.body 的型別與格式。
  3. Result 為所有 API 的統一回傳格式,讓前端只需要判斷 success
  4. Service 層保持純粹的業務邏輯,不涉及 Express 相關的 req/res,便於單元測試。
  5. 全域錯誤中介軟體 必須放在所有路由之後,且不應該在其中再次拋出錯誤(除非想讓 Node 直接 crash)。

實際應用場景

場景一:使用者註冊

  1. 路由POST /api/v1/usersvalidationMiddleware(CreateUserDto)UserController.register
  2. 流程
    • 請求先經過 DTO 驗證,若失敗回傳 VALIDATION_ERROR (400)。
    • Service 內檢查 Email 重複,若重複拋出 EMAIL_ALREADY_EXISTS (409)。
    • 成功建立後回傳 { success: true, data: user } (201)。
  3. 前端:只要檢查 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 的專案中,錯誤處理與型別安全 不是兩件孤立的事,而是相輔相成的設計原則。透過:

  1. 自訂 AppError 建立結構化錯誤資訊;
  2. 全域錯誤中介軟體 統一回傳格式;
  3. DTO + validation middleware 把入口層的資料驗證與型別保證前置化;
  4. Result 為所有 API 定義統一回傳結構;
  5. Service 層負責業務與錯誤拋出,保持 Controller 的簡潔與可測試性;

我們可以在開發階段即捕獲大多數錯誤,並在上線後提供前端一致且易於處理的回應。這種做法不僅提升 程式碼可維護性,也讓 團隊協作 更加順暢,因為每個人都清楚錯誤的類型、回傳方式以及型別合約。

實務建議:在新專案啟動時即導入上述模板,並在 CI 流程加入 TypeScript 編譯與單元測試,確保每次提交都不會破壞錯誤處理與型別安全的基礎。未來若要擴充功能,只要遵循「DTO → Service → Controller → errorHandler」的流水線,就能保持系統的穩定與可預測性。祝開發順利!