本文 AI 產出,尚未審核

TypeScript 裝飾器(Decorators)

experimentalDecorators 與 emitDecoratorMetadata 設定


簡介

在大型前端或 Node.js 專案中,型別安全與**元資料(metadata)**的管理往往是開發者最頭疼的課題。TypeScript 於 1.5 版引入的 裝飾器(Decorator)語法,提供了一種以宣告式方式在類別、屬性、方法或參數上「貼標籤」的機制,讓我們可以在執行階段取得額外資訊,進而實作依賴注入、驗證、日誌等常見功能。

然而,裝飾器仍屬於 實驗性(experimental) 功能,必須在 tsconfig.json 中顯式開啟 experimentalDecorators。若想在執行時取得參數或屬性的型別資訊,則需要再開啟 emitDecoratorMetadata,讓編譯器在產生的 JavaScript 中自動注入 design:typedesign:paramtypesdesign:returntype 等 metadata。

本篇文章將從 設定核心概念實作範例常見陷阱最佳實踐,一步步帶你掌握這兩個編譯器選項的使用方式,並說明它們在真實專案中的價值。


核心概念

1. 為什麼需要 experimentalDecorators

  • 裝飾器不是 ECMA 標準的一部分,而是 TypeScript 實驗性的語法糖。
  • 若未在 tsconfig.json 開啟 experimentalDecorators,編譯器會直接報錯:Experimental support for decorators is a feature that is subject to change...
  • 開啟後,我們即可在類別、屬性、方法、參數前使用 @ 前綴的函式或工廠函式。

2. 為什麼需要 emitDecoratorMetadata

  • 裝飾器本身只能在執行時取得「被裝飾的目標」(target) 與「屬性名稱」(propertyKey) 等資訊。
  • 若想在執行時知道 型別(例如屬性是 stringnumber,或參數是 UserDto),必須讓編譯器在輸出 JavaScript 時 自動加上 Reflect.metadata 呼叫。
  • 這些資訊會被寫入 metadata keydesign:typedesign:paramtypesdesign:returntype),可透過 reflect-metadata 套件讀取。

⚠️ 注意emitDecoratorMetadata 只會在 已開啟 experimentalDecorators 的前提下生效。

3. reflect-metadata 套件

  • TypeScript 只負責 產生 metadata,讀取 必須依賴 reflect-metadata(或其他實作)提供的全域 Reflect API。
  • 在入口檔案(如 main.ts)最上方 先 import
import "reflect-metadata";

程式碼範例

以下示範 5 個常見的裝飾器使用情境,並說明 experimentalDecoratorsemitDecoratorMetadata 的作用。

範例 1️⃣ 基礎類別裝飾器

// tsconfig.json 必須設定
// {
//   "compilerOptions": {
//     "experimentalDecorators": true,
//     "emitDecoratorMetadata": true,
//     "target": "ES6",
//     "module": "commonjs"
//   }
// }

function sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

/* @sealed 會在類別定義完成後執行 */
@sealed
class Person {
  constructor(public name: string) {}
}
  • 說明@sealed 只會在類別建構完成後呼叫一次,Object.seal 讓類別無法再被擴充。此範例不需要型別 metadata,但若想要在裝飾器內部根據類別型別做判斷,就必須開啟 emitDecoratorMetadata

範例 2️⃣ 屬性裝飾器 + 型別 metadata

import "reflect-metadata";

function logType(target: any, propertyKey: string) {
  // 取得設計時的型別 (string, Number, Boolean, Object, etc.)
  const type = Reflect.getMetadata("design:type", target, propertyKey);
  console.log(`屬性 ${propertyKey} 的型別是: ${type.name}`);
}

class Product {
  @logType
  price: number;

  @logType
  name: string;
}

/* 建立實例時會印出:
屬性 price 的型別是: Number
屬性 name 的型別是: String
*/
new Product();
  • 重點emitDecoratorMetadata 讓編譯器在 pricename 上注入 design:typeReflect.getMetadata 才能正確取得型別名稱。

範例 3️⃣ 方法參數裝飾器(依賴注入簡易實作)

import "reflect-metadata";

const INJECT_TOKEN = Symbol("INJECT_TOKEN");

// 參數裝飾器:把參數型別記錄到 methodMetadata 中
function Inject(token?: any) {
  return (target: any, propertyKey: string | symbol, parameterIndex: number) => {
    const existingInjectedParams: any[] =
      Reflect.getOwnMetadata(INJECT_TOKEN, target, propertyKey) || [];

    // 若使用者未自訂 token,直接使用參數的設計型別
    const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target, propertyKey);
    const type = token ?? paramTypes[parameterIndex];

    existingInjectedParams[parameterIndex] = type;
    Reflect.defineMetadata(INJECT_TOKEN, existingInjectedParams, target, propertyKey);
  };
}

// 假設有一個服務
class LoggerService {
  log(msg: string) {
    console.log("[LOG]", msg);
  }
}

// 目標類別
class UserController {
  // 透過 @Inject 取得 LoggerService 實例
  constructor(@Inject() private logger: LoggerService) {}

  getUser(id: number) {
    this.logger.log(`取得使用者 ${id}`);
    return { id, name: "Alice" };
  }
}

/* 建立簡易的 DI 容器 */
function resolve<T>(target: new (...args: any[]) => T): T {
  const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target) || [];
  const injectedParams: any[] = Reflect.getOwnMetadata(INJECT_TOKEN, target) || [];

  const args = paramTypes.map((type, index) => {
    const token = injectedParams[index] ?? type;
    // 這裡直接 new,實務上會使用更完整的容器
    return new token();
  });

  return new target(...args);
}

/* 測試 */
const controller = resolve(UserController);
controller.getUser(42);
  • 說明
    1. @Inject 會在編譯時把參數的 設計型別 (design:paramtypes) 取出,並存入自訂的 metadata (INJECT_TOKEN)。
    2. resolve 函式根據這些 metadata 建構相依物件,完成 依賴注入
    3. 若未開啟 emitDecoratorMetadatadesign:paramtypes 會是 undefined,導致 DI 失效。

範例 4️⃣ 方法裝飾器 + 回傳型別 metadata

import "reflect-metadata";

function LogReturn(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;

  // 取得回傳型別
  const returnType = Reflect.getMetadata("design:returntype", target, propertyKey);
  console.log(`方法 ${propertyKey} 的回傳型別是: ${returnType?.name}`);

  descriptor.value = function (...args: any[]) {
    const result = originalMethod.apply(this, args);
    console.log(`呼叫 ${propertyKey},回傳值 =>`, result);
    return result;
  };
}

class MathUtil {
  @LogReturn
  add(a: number, b: number): number {
    return a + b;
  }
}

/* 執行 */
new MathUtil().add(3, 5);
/* Console:
方法 add 的回傳型別是: Number
呼叫 add,回傳值 => 8
*/
  • 重點design:returntype 只能在 emitDecoratorMetadata 開啟時產生,否則會得到 undefined

範例 5️⃣ 結合 class-validator(實務驗證)

class-validator 內部已使用 emitDecoratorMetadata 取得屬性型別,以下示範如何自行實作類似機制。

import "reflect-metadata";
import { validate, IsString, IsInt, Min } from "class-validator";

class CreateUserDto {
  @IsString()
  username!: string;

  @IsInt()
  @Min(0)
  age!: number;
}

/* 直接使用 class-validator 的 validate 方法 */
async function demo() {
  const dto = new CreateUserDto();
  dto.username = "bob";
  dto.age = -5; // 不符合 @Min(0)

  const errors = await validate(dto);
  console.log(errors);
}

demo();
  • 說明class-validator 依賴 emitDecoratorMetadata 把屬性的 設計型別StringNumber)注入,讓驗證器能自動判斷要套用哪種驗證規則。若編譯選項關閉,驗證將會失效或拋出錯誤。

常見陷阱與最佳實踐

陷阱 可能的結果 解決方式
忘記在 tsconfig.json 開啟 experimentalDecorators 編譯錯誤 Experimental support for decorators... 確認 compilerOptions 中有 "experimentalDecorators": true
未開啟 emitDecoratorMetadata,卻使用 Reflect.getMetadata("design:type") 取得 undefined,導致 DI、驗證失效 同時開啟 "emitDecoratorMetadata": true
未安裝 reflect-metadata,或未在入口檔案 import Reflect.getMetadataundefined,拋出 TypeError npm i reflect-metadata --save,並在最上層 import "reflect-metadata"
目標執行環境不支援 ES6+ Reflect API 运行时错误 使用 polyfill 或將編譯目標降至 ES5 並自行引入相容套件
裝飾器順序不符合預期 某些裝飾器的副作用被覆蓋 了解 裝飾器執行順序
1. Parameter decorators
2. Method / Accessor decorators
3. Property decorators
4. Class decorators
在第三方套件 (e.g., NestJS) 中自行覆寫 design:paramtypes 破壞框架的 DI 機制 盡量避免直接修改 design:paramtypes,使用框架提供的自訂 token 機制

最佳實踐

  1. 始終在 tsconfig.json 明確宣告
    {
      "compilerOptions": {
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true,
        "target": "ES2020",
        "module": "commonjs",
        "strict": true
      }
    }
    
  2. 在專案入口處一次性 import reflect-metadata,避免遺漏。
  3. 只在需要型別資訊的裝飾器上依賴 metadata,其餘簡單的標記可不開 emitDecoratorMetadata,減少產出檔案大小。
  4. 配合 lint 規則(如 @typescript-eslint/explicit-module-boundary-types)確保裝飾器參數與回傳型別明確。
  5. 測試裝飾器行為:使用 Jest 或 Vitest 撰寫單元測試,驗證 metadata 是否正確產生。

實際應用場景

場景 為什麼需要 experimentalDecorators / emitDecoratorMetadata
依賴注入 (DI) 框架(NestJS、Inversify) 透過 @Injectable()@Inject() 讀取建構子參數型別,自動解析相依物件。
資料驗證(class-validator、class-transformer) 依賴屬性型別自動轉換 JSON 為實例,並根據型別套用驗證規則。
API 文件產生(Swagger / OpenAPI) 讀取控制器方法參數與回傳型別,生成完整的 API schema。
ORM 映射(TypeORM) @Entity(), @Column() 需要知道屬性型別才能對應資料庫欄位類型。
AOP(Aspect‑Oriented Programming) @Log(), @Transaction() 等切面裝飾器可依據方法簽名決定攔截行為。

案例簡述:在 NestJS 中,@Controller()@Get()@Body() 等裝飾器全靠 emitDecoratorMetadata 取得參數型別,才能自動把 HTTP 請求的 JSON 轉成 DTO,並交給 class-validator 驗證。若關閉這兩個編譯選項,整個請求管線會失去型別安全,導致 runtime 錯誤。


總結

  • experimentalDecorators 是開啟裝飾器語法的必要旗標,沒有它,任何 @xxx 都會被視為語法錯誤。
  • emitDecoratorMetadata 則是讓 TypeScript 在編譯時自動把型別資訊寫入 Reflect metadata,為依賴注入、驗證、ORM、API 文件等功能提供基礎。
  • 兩者 配合 reflect-metadata 使用,才能在執行階段取得設計時的類別、屬性、參數與回傳型別。
  • 在實務開發中,只在需要型別資訊的裝飾器上開啟 emitDecoratorMetadata,其餘情況保持關閉,可減少產出檔案大小與編譯時間。
  • 最後,遵守裝飾器執行順序、編寫單元測試、並在 tsconfig.json 中明確設定,是避免常見陷阱、打造可維護大型 TypeScript 專案的關鍵。

透過本文的概念講解與實作範例,你已經掌握了如何正確設定與使用 experimentalDecoratorsemitDecoratorMetadata,接下來可以在自己的專案中實作 DI、驗證或自訂 AOP,讓程式碼更具可讀性、可測試性與可擴充性。祝開發順利! 🚀