本文 AI 產出,尚未審核

TypeScript 裝飾器(Decorators)實務應用:類別驗證與依賴注入 (DI)


簡介

在大型前端或 Node.js 專案中,類別(class)往往承擔核心業務邏輯,而隨著需求的成長,程式碼會逐漸變得繁雜。

  • 裝飾器(Decorator)提供了一種**聲明式(declarative)**的方式,讓我們可以在不改變原始類別實作的前提下,為類別、屬性、方法或參數「貼上」額外行為。
  • 透過裝飾器,我們可以把 驗證(validation)依賴注入(Dependency Injection, DI) 等橫切關注點(cross‑cutting concerns)抽離出來,提升程式碼的可讀性、可測試性與可維護性。

本文將從 核心概念 出發,示範如何在 TypeScript 中實作 類別驗證DI,並說明常見陷阱、最佳實踐與實務場景,幫助初學者到中階開發者快速上手。


核心概念

1. 裝飾器的基本類型

裝飾器類型 作用目標 範例語法
類別裝飾器 (@ClassDecorator) 整個 class @Controller('/api') class UserController {}
屬性裝飾器 (@PropertyDecorator) 欄位/屬性 @Inject() private readonly repo: UserRepo;
方法裝飾器 (@MethodDecorator) 類別方法 @Get('/') getAll() {}
參數裝飾器 (@ParameterDecorator) 方法參數 @Body() data: CreateUserDto

:裝飾器必須在 tsconfig.json 中啟用 experimentalDecoratorsemitDecoratorMetadata(若需要型別資訊)。

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    // 其他設定...
  }
}

2. 為何使用裝飾器做驗證與 DI?

  • 分離關注點:驗證規則與業務邏輯分開,讓類別保持單一職責(Single Responsibility Principle)。
  • 可重用性:相同的驗證或注入邏輯只需要寫一次裝飾器,即可在多個類別或屬性間共享。
  • 可測試性:測試時只需要 mock 裝飾器的行為,而不必改動原始類別。

程式碼範例

以下示範三個實務常見的裝飾器:屬性驗證、類別驗證、以及依賴注入。每個範例皆附上說明與使用情境。

2.1 屬性驗證裝飾器:@IsEmail@MinLength

先建立一個簡易的驗證框架,利用 reflect-metadata 取得屬性型別與自訂的驗證規則。

npm i reflect-metadata
// validator.ts
import 'reflect-metadata';

export const VALIDATION_METADATA_KEY = Symbol('validation');

/** 內建驗證函式 */
const validators = {
  email: (value: any) => typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
  minLength: (len: number) => (value: any) => typeof value === 'string' && value.length >= len,
};

/** 裝飾器工廠 */
export function IsEmail() {
  return Reflect.metadata(VALIDATION_METADATA_KEY, { type: 'email' });
}

export function MinLength(length: number) {
  return Reflect.metadata(VALIDATION_METADATA_KEY, { type: 'minLength', length });
}

/** 針對整個物件執行驗證 */
export function validate(obj: any): string[] {
  const errors: string[] = [];
  for (const key of Object.keys(obj)) {
    const meta = Reflect.getMetadata(VALIDATION_METADATA_KEY, obj, key);
    if (!meta) continue; // 沒有驗證規則

    const value = obj[key];
    let valid = true;
    switch (meta.type) {
      case 'email':
        valid = validators.email(value);
        break;
      case 'minLength':
        valid = validators.minLength(meta.length)(value);
        break;
    }
    if (!valid) errors.push(`屬性 ${key} 不符合 ${meta.type} 規則`);
  }
  return errors;
}

使用範例

// user.dto.ts
import { IsEmail, MinLength } from './validator';

export class CreateUserDto {
  @IsEmail()
  email!: string;

  @MinLength(6)
  password!: string;
}

// controller.ts
import { validate } from './validator';
import { CreateUserDto } from './user.dto';

function createUser(payload: any) {
  const dto = Object.assign(new CreateUserDto(), payload);
  const errors = validate(dto);
  if (errors.length) {
    throw new Error('驗證失敗: ' + errors.join(', '));
  }
  // 進一步的業務邏輯...
}

重點:屬性驗證裝飾器本身不會直接拋錯,而是把驗證資訊寫入 metadata,由外部的 validate 函式統一處理,保持了 單一職責


2.2 類別驗證裝飾器:@ValidateClass

有時候驗證規則需要跨屬性比較(例如兩個密碼欄位必須相等),這時候使用 類別裝飾器 更合適。

// class-validator.ts
import 'reflect-metadata';
import { validate } from './validator';

export const CLASS_VALIDATORS_KEY = Symbol('classValidators');

/** 類別驗證函式 */
export type ClassValidatorFn = (instance: any) => string | null;

/** 裝飾器:註冊類別層級驗證 */
export function ValidateClass(fn: ClassValidatorFn) {
  return (target: Function) => {
    const validators: ClassValidatorFn[] =
      Reflect.getMetadata(CLASS_VALIDATORS_KEY, target) || [];
    validators.push(fn);
    Reflect.defineMetadata(CLASS_VALIDATORS_KEY, validators, target);
  };
}

/** 執行所有類別驗證 */
export function runClassValidators(instance: any): string[] {
  const ctor = instance.constructor;
  const validators: ClassValidatorFn[] =
    Reflect.getMetadata(CLASS_VALIDATORS_KEY, ctor) || [];
  const errors: string[] = [];

  // 先跑屬性驗證
  errors.push(...validate(instance));

  // 再跑類別驗證
  for (const fn of validators) {
    const err = fn(instance);
    if (err) errors.push(err);
  }
  return errors;
}

使用範例:密碼確認

// register.dto.ts
import { IsEmail, MinLength } from './validator';
import { ValidateClass, runClassValidators } from './class-validator';

export class RegisterDto {
  @IsEmail()
  email!: string;

  @MinLength(6)
  password!: string;

  @MinLength(6)
  confirmPassword!: string;
}

/** 類別層級驗證:兩次密碼必須相等 */
@ValidateClass((obj: RegisterDto) =>
  obj.password !== obj.confirmPassword
    ? '密碼與確認密碼不相符'
    : null
)
export class RegisterDtoValidated extends RegisterDto {}

/* 在服務或控制器中使用 */
function register(payload: any) {
  const dto = Object.assign(new RegisterDtoValidated(), payload);
  const errors = runClassValidators(dto);
  if (errors.length) throw new Error('驗證失敗: ' + errors.join(', '));
  // 實際註冊流程...
}

說明

  • @ValidateClass 允許我們在 類別層級 加入任意驗證函式。
  • 透過 runClassValidators 同時執行「屬性驗證」與「類別驗證」,保持驗證流程一致。

2.3 依賴注入(DI)裝飾器:@Injectable & @Inject

DI 的核心概念是 將相依物件的建立交給容器管理,而不是在類別內手動 new。下面示範一個簡易的 DI 容器與兩個裝飾器。

// di-container.ts
type Constructor<T = any> = new (...args: any[]) => T;

class Container {
  private providers = new Map<string, any>();

  /** 註冊 provider */
  register<T>(token: string, provider: Constructor<T>) {
    const deps = Reflect.getMetadata('design:paramtypes', provider) || [];
    const injections = deps.map((dep: any) => this.resolve(dep.name));
    const instance = new provider(...injections);
    this.providers.set(token, instance);
  }

  /** 解析 token */
  resolve<T>(token: string): T {
    const instance = this.providers.get(token);
    if (!instance) {
      throw new Error(`未註冊的依賴: ${token}`);
    }
    return instance;
  }
}

/** 單例容器 */
export const DIContainer = new Container();

/** 類別裝飾器:標記為可被注入 */
export function Injectable(token?: string) {
  return (target: Constructor) => {
    const id = token || target.name;
    DIContainer.register(id, target);
  };
}

/** 屬性裝飾器:注入相依 */
export function Inject(token?: string) {
  return (target: any, propertyKey: string) => {
    const type = Reflect.getMetadata('design:type', target, propertyKey);
    const id = token || type.name;
    Object.defineProperty(target, propertyKey, {
      get: () => DIContainer.resolve(id),
      enumerable: true,
      configurable: true,
    });
  };
}

使用範例:服務層與控制器

// user-repo.ts
@Injectable()
export class UserRepository {
  private users: { email: string; password: string }[] = [];

  create(user: { email: string; password: string }) {
    this.users.push(user);
    return user;
  }

  findByEmail(email: string) {
    return this.users.find(u => u.email === email);
  }
}

// user-service.ts
@Injectable()
export class UserService {
  @Inject()
  private readonly repo!: UserRepository; // 由 DI 容器自動注入

  register(email: string, password: string) {
    if (this.repo.findByEmail(email)) {
      throw new Error('使用者已存在');
    }
    return this.repo.create({ email, password });
  }
}

// user-controller.ts
@Injectable()
export class UserController {
  @Inject()
  private readonly service!: UserService;

  async register(req: any, res: any) {
    try {
      const { email, password } = req.body;
      const user = this.service.register(email, password);
      res.json({ success: true, user });
    } catch (e: any) {
      res.status(400).json({ success: false, message: e.message });
    }
  }
}

/* 啟動應用程式 */
import { UserController } from './user-controller';
import express from 'express';
const app = express();
app.use(express.json());

const controller = DIContainer.resolve<UserController>('UserController');
app.post('/register', (req, res) => controller.register(req, res));

app.listen(3000, () => console.log('Server listening on http://localhost:3000'));

要點

  • @Injectable 於類別建立時自動註冊到容器。
  • @Inject 於屬性上使用,透過 getter 延遲注入,確保單例行為。
  • 整個流程只需在最外層呼叫 DIContainer.resolve,其餘相依關係自動解決。

常見陷阱與最佳實踐

陷阱 說明 解法 / 最佳實踐
忘記啟用 experimentalDecorators 裝飾器會直接編譯錯誤。 tsconfig.json 中設定 experimentalDecorators: true
Metadata 丟失 未安裝 reflect-metadata 或未在入口檔 import 'reflect-metadata' 確保所有執行環境(Node、Webpack、Vite)都有載入此套件。
循環依賴 (Circular Dependency) DI 容器在建立 A 時需要 B,B 又需要 A,會造成 stack overflow。 使用 延遲注入(getter)或 抽象介面 token,避免直接在建構子中相互依賴。
驗證錯誤訊息不友善 直接回傳布林結果會失去錯誤上下文。 在驗證函式內返回具體錯誤字串,並在 validate 中收集。
過度使用裝飾器 把所有邏輯都塞進裝飾器,導致難以追蹤。 遵守單一職責:裝飾器只負責「宣告」行為,實際邏輯放在可測試的函式或服務中。
缺乏型別安全 裝飾器的參數若使用 any,會失去 TypeScript 的優勢。 盡量使用 unknown 並在 runtime 做型別守衛(type guard)。

最佳實踐小結

  1. 保持裝飾器輕量:只做「註冊」或「標記」工作,所有業務邏輯放在外部函式。
  2. 使用 reflect-metadata 取得型別資訊,讓驗證與 DI 能自動推斷。
  3. 集中管理容器:建立單一 DIContainer,避免在多處自行 new
  4. 提供明確錯誤訊息:驗證失敗時返回可直接給使用者的文字,而非堆疊訊息。
  5. 單元測試:針對裝飾器本身與被裝飾的類別分別撰寫測試,確保兩者皆可獨立驗證。

實際應用場景

場景 為何使用裝飾器 範例
REST API 請求資料驗證 透過 @IsEmail@MinLength 等屬性裝飾器,讓 DTO(Data Transfer Object)自行負責資料完整性。 NestJS 的 class-validator
表單驗證 前端表單模型可直接掛上驗證裝飾器,與後端共用同一套驗證規則。 Angular Reactive Form + 自訂裝飾器。
服務層的依賴管理 @Injectable@Inject 讓服務之間的相依關係清晰且易於 mock。 大型微服務或 monorepo 中的共享套件。
跨欄位驗證 @ValidateClass 允許在 DTO 內實作「密碼相等」或「起始日期 < 結束日期」等商業規則。 訂票系統的日期檢核。
AOP(Aspect‑Oriented Programming) 方法裝飾器可加入日誌、快取、權限檢查等橫切關注點。 @Log()@Cacheable() 等。

總結

  • 裝飾器 是 TypeScript 提供的強大語法糖,讓我們能以宣告式的方式把 驗證依賴注入 等橫切關注點抽離出來。
  • 透過 metadataDI Container,可以在執行時自動取得型別資訊,實作出 可重用、可測試 的程式碼。
  • 實務上,屬性驗證類別驗證DI 常同時出現在 API、表單、服務層等多個層面,掌握這三種裝飾器的寫法與最佳實踐,能顯著提升專案的可維護性與開發效率。

最後,建議在實作前先 規劃好驗證規則與相依關係的邊界,再以裝飾器作為「聲明」層,讓程式碼保持乾淨、易讀、易測。祝你在 TypeScript 的裝飾器世界裡玩得開心! 🚀