TypeScript 裝飾器(Decorators)
主題:參數裝飾器(Parameter Decorator)
簡介
在大型的 TypeScript 專案中,程式碼的可讀性、可維護性與可擴充性往往是決定開發效率的關鍵因素。裝飾器(Decorators)提供了一種宣告式(declarative)的方式,讓開發者可以在不改變原始類別或方法本體的前提下,為它們「貼上」額外的行為或資訊。
參數裝飾器是四種裝飾器(類別、屬性、方法、參數)中最細粒度的一種,它能在執行時取得參數的型別資訊、位置、以及自訂的元資料,從而支援像是驗證、日誌、依賴注入等常見需求。掌握參數裝飾器的使用,能讓你的 API 介面更具表達力,也能減少重複的檢查程式碼。
核心概念
1. 什麼是參數裝飾器?
參數裝飾器是一個函式,它會在編譯階段被呼叫,接收三個參數:
| 參數 | 說明 |
|---|---|
target |
若裝飾的是 靜態方法,則為該類別的建構子(constructor);若是 實例方法,則為該類別的原型(prototype)。 |
propertyKey |
被裝飾的 方法名稱(字串)。對於建構子參數則為 undefined。 |
parameterIndex |
參數在參數列表中的 索引(從 0 開始)。 |
function MyParamDecorator(target: Object, propertyKey: string | symbol, parameterIndex: number) {
// 實作內容
}
注意:參數裝飾器本身不會改變參數的值,它只能透過
Reflect.defineMetadata(或自訂機制)把資訊寫入目標物件,之後再由其他裝飾器或程式碼讀取。
2. 為什麼需要參數裝飾器?
- 集中化驗證:將所有參數的驗證規則寫在裝飾器裡,控制器只負責業務邏輯。
- 依賴注入 (DI):在框架(如 NestJS)中,參數裝飾器會告訴容器「這個參數要注入哪個服務」。
- 自動生成 API 文件:利用參數的型別與自訂描述,產生 Swagger、OpenAPI 等文件。
- 日誌與追蹤:自動記錄函式呼叫時傳入的參數值,減少手動
console.log。
3. 基本使用方式
下面是一個最簡單的參數裝飾器範例,僅在執行時把參數索引寫入 Reflect 的 metadata 中:
import "reflect-metadata";
function LogParam(target: Object, propertyKey: string | symbol, parameterIndex: number) {
const existingParams: number[] =
Reflect.getOwnMetadata("log:param", target, propertyKey) || [];
existingParams.push(parameterIndex);
Reflect.defineMetadata("log:param", existingParams, target, propertyKey);
}
說明
reflect-metadata必須先安裝 (npm i reflect-metadata) 並在入口檔案import "reflect-metadata"。- 這個裝飾器會把被裝飾的參數索引收集起來,供之後的「方法裝飾器」使用。
4. 搭配方法裝飾器使用
單獨的參數裝飾器只能儲存資訊,真正的「行為」往往在方法裝飾器裡完成。以下示範如何在方法執行前,根據參數裝飾器紀錄的索引,自動列印參數值:
function LogMethod(
target: Object,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const paramIndexes: number[] =
Reflect.getOwnMetadata("log:param", target, propertyKey) || [];
paramIndexes.forEach((idx) => {
console.log(`🪵 ${String(propertyKey)} - param[${idx}]:`, args[idx]);
});
return originalMethod.apply(this, args);
};
return descriptor;
}
說明
LogMethod會在原始方法執行前,根據先前LogParam記錄的索引輸出參數。- 這樣的組合讓「宣告式」的日誌變得非常簡潔。
5. 完整範例:驗證必填參數
以下示範如何利用參數裝飾器實作 必填驗證(@Required):
import "reflect-metadata";
/* ---------- 參數裝飾器 ---------- */
function Required(target: Object, propertyKey: string | symbol, index: number) {
const existingRequired: number[] =
Reflect.getOwnMetadata("required:param", target, propertyKey) || [];
existingRequired.push(index);
Reflect.defineMetadata("required:param", existingRequired, target, propertyKey);
}
/* ---------- 方法裝飾器 ---------- */
function Validate(
target: Object,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
const requiredParams: number[] =
Reflect.getOwnMetadata("required:param", target, propertyKey) || [];
for (const idx of requiredParams) {
if (args[idx] === null || args[idx] === undefined) {
throw new Error(
`❌ 参数错误:${String(propertyKey)} 的第 ${idx + 1} 個參數是必填的。`
);
}
}
return original.apply(this, args);
};
return descriptor;
}
/* ---------- 使用範例 ---------- */
class UserService {
@Validate
createUser(@Required name: string, @Required age: number, role?: string) {
console.log(`✅ 建立使用者:${name}, ${age} 歲, 角色 ${role ?? "一般使用者"}`);
}
}
const svc = new UserService();
svc.createUser("Alice", 30); // ✅ 正常
// svc.createUser(undefined, 30); // ❌ 會拋出錯誤
重點
@Required只負責「標記」參數位置。- 真正的檢查邏輯寫在
@Validate方法裝飾器裡,保持職責分離。- 這樣的寫法在大型 API 中非常常見,能讓控制器的驗證規則集中管理。
6. 參數裝飾器與依賴注入(DI)
在 NestJS 等框架中,常見的 @Inject()、@Body()、@Param() 都是參數裝飾器。以下以簡化版的 DI 為例,說明原理:
const container = new Map<string, any>();
function Injectable(token: string) {
return function (ctor: new (...args: any[]) => any) {
container.set(token, new ctor());
};
}
function Inject(token: string) {
return function (target: Object, propertyKey: string | symbol, index: number) {
const deps = Reflect.getOwnMetadata("di:params", target, propertyKey) || [];
deps[index] = token;
Reflect.defineMetadata("di:params", deps, target, propertyKey);
};
}
/* --------- 範例類別 --------- */
@Injectable("logger")
class Logger {
log(msg: string) {
console.log("[Log]", msg);
}
}
class AppService {
// 透過參數裝飾器聲明需要注入 Logger
constructor(@Inject("logger") private logger: Logger) {}
run() {
this.logger.log("App started!");
}
}
/* --------- 手動解析 DI --------- */
function resolve<T>(ctor: new (...args: any[]) => T): T {
const paramTokens: string[] =
Reflect.getOwnMetadata("di:params", ctor) || [];
const params = paramTokens.map((t) => container.get(t));
return new ctor(...params);
}
/* --------- 使用 --------- */
const app = resolve(AppService);
app.run(); // 輸出: [Log] App started!
說明
@Inject只負責把 token 記錄到參數位置。resolve函式在建立實例時,根據 metadata 取得相對應的實例,完成注入。- 真實框架會有更完整的作用域、循環依賴偵測等機制,但核心概念即是「利用參數裝飾器記錄依賴資訊」。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
忘記 emitDecoratorMetadata |
若 tsconfig.json 沒開啟 emitDecoratorMetadata,Reflect.getMetadata 會取得不到型別資訊。 |
在 tsconfig.json 中加入 "experimentalDecorators": true, "emitDecoratorMetadata": true。 |
| 多次套用同一參數裝飾器 | 同一參數若被多個裝飾器標記,metadata 可能被覆寫。 | 使用陣列或 Set 累積資訊,避免直接覆寫。 |
| 參數索引錯位 | 在繼承或重寫方法時,子類別的參數索引仍參考父類別的 metadata,可能造成錯誤。 | 在子類別重新定義裝飾器,或在設計時避免跨層級的參數裝飾。 |
| 過度依賴全域 metadata | 大量使用 reflect-metadata 會在執行階段產生額外記憶體開銷。 |
只在需要的地方使用,或自行實作輕量的 metadata 存儲(如 WeakMap)。 |
| 裝飾器執行順序不明 | 裝飾器的執行順序是由下至上(參數 → 方法 → 類別),若混用可能產生意外結果。 | 明確規劃裝飾器的層級,並在文件中註明執行順序。 |
最佳實踐:
- 保持單一職責:參數裝飾器只負責「標記」或「收集」資訊,真正的業務邏輯放在方法或類別裝飾器。
- 使用
WeakMap儲存自訂 metadata:避免全域污染且可自動釋放記憶體。 - 寫單元測試:尤其是驗證裝飾器所產生的 metadata 是否正確。
- 文件化每個裝飾器的行為與限制,讓團隊成員能快速上手。
實際應用場景
| 場景 | 需求 | 參數裝飾器的角色 |
|---|---|---|
| REST API 請求驗證 | 檢查 body、query、params 是否符合規範 |
@Body(), @Query(), @Param() 取得對應資料並自動套用驗證管道 |
| 日誌追蹤 | 紀錄每次服務呼叫的參數與回傳值 | @LogParam + @LogMethod 組合,無侵入式記錄 |
| 多語系訊息 | 根據使用者語系自動注入翻譯服務 | @Inject('i18n') 把翻譯器注入到控制器方法參數 |
| 權限驗證 | 根據使用者角色檢查特定參數是否有權限操作 | @Roles('admin') 標記參數,配合方法裝飾器檢查 |
| 自動生成 Swagger | 從程式碼抽取參數型別與描述產生 API 文件 | @ApiProperty()(參數裝飾器)提供 metadata,Swagger 生成器讀取後產出文件 |
範例:在 NestJS 中的
@Body()裝飾器背後,就是一段類似前述Inject與LogParam的實作,只不過它會把 HTTP 請求的 body 物件注入到參數位置。
總結
參數裝飾器是 TypeScript 裝飾器族 中最細緻、最具彈性的工具,透過它我們可以:
- 集中管理驗證、DI、日誌等橫切關注點,讓核心業務程式碼保持乾淨。
- 利用
reflect-metadata或自訂儲存機制,在執行時取得參數的型別與自訂資訊。 - 與方法或類別裝飾器搭配,在不同階段完成「標記 → 行為」的分離。
在實務開發中,適度使用參數裝飾器 能提升程式碼可讀性、減少重覆檢查,並為未來的功能擴充(如自動產生文件、統一錯誤處理)奠定堅實基礎。只要遵守「單一職責」與「避免過度依賴全域 metadata」的原則,你就能在大型 TypeScript 專案中,輕鬆運用參數裝飾器打造乾淨、可維護的程式碼基礎。祝你寫程式愉快! 🎉