本文 AI 產出,尚未審核

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. 為什麼需要參數裝飾器?

  1. 集中化驗證:將所有參數的驗證規則寫在裝飾器裡,控制器只負責業務邏輯。
  2. 依賴注入 (DI):在框架(如 NestJS)中,參數裝飾器會告訴容器「這個參數要注入哪個服務」。
  3. 自動生成 API 文件:利用參數的型別與自訂描述,產生 Swagger、OpenAPI 等文件。
  4. 日誌與追蹤:自動記錄函式呼叫時傳入的參數值,減少手動 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 沒開啟 emitDecoratorMetadataReflect.getMetadata 會取得不到型別資訊。 tsconfig.json 中加入 "experimentalDecorators": true, "emitDecoratorMetadata": true
多次套用同一參數裝飾器 同一參數若被多個裝飾器標記,metadata 可能被覆寫。 使用陣列或 Set 累積資訊,避免直接覆寫。
參數索引錯位 在繼承或重寫方法時,子類別的參數索引仍參考父類別的 metadata,可能造成錯誤。 在子類別重新定義裝飾器,或在設計時避免跨層級的參數裝飾。
過度依賴全域 metadata 大量使用 reflect-metadata 會在執行階段產生額外記憶體開銷。 只在需要的地方使用,或自行實作輕量的 metadata 存儲(如 WeakMap)。
裝飾器執行順序不明 裝飾器的執行順序是由下至上(參數 → 方法 → 類別),若混用可能產生意外結果。 明確規劃裝飾器的層級,並在文件中註明執行順序。

最佳實踐

  1. 保持單一職責:參數裝飾器只負責「標記」或「收集」資訊,真正的業務邏輯放在方法或類別裝飾器。
  2. 使用 WeakMap 儲存自訂 metadata:避免全域污染且可自動釋放記憶體。
  3. 寫單元測試:尤其是驗證裝飾器所產生的 metadata 是否正確。
  4. 文件化每個裝飾器的行為與限制,讓團隊成員能快速上手。

實際應用場景

場景 需求 參數裝飾器的角色
REST API 請求驗證 檢查 bodyqueryparams 是否符合規範 @Body(), @Query(), @Param() 取得對應資料並自動套用驗證管道
日誌追蹤 紀錄每次服務呼叫的參數與回傳值 @LogParam + @LogMethod 組合,無侵入式記錄
多語系訊息 根據使用者語系自動注入翻譯服務 @Inject('i18n') 把翻譯器注入到控制器方法參數
權限驗證 根據使用者角色檢查特定參數是否有權限操作 @Roles('admin') 標記參數,配合方法裝飾器檢查
自動生成 Swagger 從程式碼抽取參數型別與描述產生 API 文件 @ApiProperty()(參數裝飾器)提供 metadata,Swagger 生成器讀取後產出文件

範例:在 NestJS 中的 @Body() 裝飾器背後,就是一段類似前述 InjectLogParam 的實作,只不過它會把 HTTP 請求的 body 物件注入到參數位置。


總結

參數裝飾器是 TypeScript 裝飾器族 中最細緻、最具彈性的工具,透過它我們可以:

  • 集中管理驗證、DI、日誌等橫切關注點,讓核心業務程式碼保持乾淨。
  • 利用 reflect-metadata 或自訂儲存機制,在執行時取得參數的型別與自訂資訊。
  • 與方法或類別裝飾器搭配,在不同階段完成「標記 → 行為」的分離。

在實務開發中,適度使用參數裝飾器 能提升程式碼可讀性、減少重覆檢查,並為未來的功能擴充(如自動產生文件、統一錯誤處理)奠定堅實基礎。只要遵守「單一職責」與「避免過度依賴全域 metadata」的原則,你就能在大型 TypeScript 專案中,輕鬆運用參數裝飾器打造乾淨、可維護的程式碼基礎。祝你寫程式愉快! 🎉