本文 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中啟用experimentalDecorators與emitDecoratorMetadata(若需要型別資訊)。
{
"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)。 |
最佳實踐小結:
- 保持裝飾器輕量:只做「註冊」或「標記」工作,所有業務邏輯放在外部函式。
- 使用
reflect-metadata取得型別資訊,讓驗證與 DI 能自動推斷。 - 集中管理容器:建立單一
DIContainer,避免在多處自行new。 - 提供明確錯誤訊息:驗證失敗時返回可直接給使用者的文字,而非堆疊訊息。
- 單元測試:針對裝飾器本身與被裝飾的類別分別撰寫測試,確保兩者皆可獨立驗證。
實際應用場景
| 場景 | 為何使用裝飾器 | 範例 |
|---|---|---|
| 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 提供的強大語法糖,讓我們能以宣告式的方式把 驗證 與 依賴注入 等橫切關注點抽離出來。
- 透過 metadata 與 DI Container,可以在執行時自動取得型別資訊,實作出 可重用、可測試 的程式碼。
- 實務上,屬性驗證、類別驗證 與 DI 常同時出現在 API、表單、服務層等多個層面,掌握這三種裝飾器的寫法與最佳實踐,能顯著提升專案的可維護性與開發效率。
最後,建議在實作前先 規劃好驗證規則與相依關係的邊界,再以裝飾器作為「聲明」層,讓程式碼保持乾淨、易讀、易測。祝你在 TypeScript 的裝飾器世界裡玩得開心! 🚀