TypeScript 裝飾器(Decorators)
experimentalDecorators 與 emitDecoratorMetadata 設定
簡介
在大型前端或 Node.js 專案中,型別安全與**元資料(metadata)**的管理往往是開發者最頭疼的課題。TypeScript 於 1.5 版引入的 裝飾器(Decorator)語法,提供了一種以宣告式方式在類別、屬性、方法或參數上「貼標籤」的機制,讓我們可以在執行階段取得額外資訊,進而實作依賴注入、驗證、日誌等常見功能。
然而,裝飾器仍屬於 實驗性(experimental) 功能,必須在 tsconfig.json 中顯式開啟 experimentalDecorators。若想在執行時取得參數或屬性的型別資訊,則需要再開啟 emitDecoratorMetadata,讓編譯器在產生的 JavaScript 中自動注入 design:type、design:paramtypes、design: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) 等資訊。
- 若想在執行時知道 型別(例如屬性是
string、number,或參數是UserDto),必須讓編譯器在輸出 JavaScript 時 自動加上Reflect.metadata呼叫。 - 這些資訊會被寫入 metadata key(
design:type、design:paramtypes、design:returntype),可透過reflect-metadata套件讀取。
⚠️ 注意:
emitDecoratorMetadata只會在 已開啟experimentalDecorators的前提下生效。
3. reflect-metadata 套件
- TypeScript 只負責 產生 metadata,讀取 必須依賴
reflect-metadata(或其他實作)提供的全域ReflectAPI。 - 在入口檔案(如
main.ts)最上方 先 import:
import "reflect-metadata";
程式碼範例
以下示範 5 個常見的裝飾器使用情境,並說明 experimentalDecorators 與 emitDecoratorMetadata 的作用。
範例 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讓編譯器在price與name上注入design:type,Reflect.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);
- 說明:
@Inject會在編譯時把參數的 設計型別 (design:paramtypes) 取出,並存入自訂的 metadata (INJECT_TOKEN)。resolve函式根據這些 metadata 建構相依物件,完成 依賴注入。- 若未開啟
emitDecoratorMetadata,design: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把屬性的 設計型別(String、Number)注入,讓驗證器能自動判斷要套用哪種驗證規則。若編譯選項關閉,驗證將會失效或拋出錯誤。
常見陷阱與最佳實踐
| 陷阱 | 可能的結果 | 解決方式 |
|---|---|---|
忘記在 tsconfig.json 開啟 experimentalDecorators |
編譯錯誤 Experimental support for decorators... |
確認 compilerOptions 中有 "experimentalDecorators": true |
未開啟 emitDecoratorMetadata,卻使用 Reflect.getMetadata("design:type") |
取得 undefined,導致 DI、驗證失效 |
同時開啟 "emitDecoratorMetadata": true |
未安裝 reflect-metadata,或未在入口檔案 import |
Reflect.getMetadata 為 undefined,拋出 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 機制 |
最佳實踐
- 始終在
tsconfig.json明確宣告{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true, "target": "ES2020", "module": "commonjs", "strict": true } } - 在專案入口處一次性 import
reflect-metadata,避免遺漏。 - 只在需要型別資訊的裝飾器上依賴 metadata,其餘簡單的標記可不開
emitDecoratorMetadata,減少產出檔案大小。 - 配合 lint 規則(如
@typescript-eslint/explicit-module-boundary-types)確保裝飾器參數與回傳型別明確。 - 測試裝飾器行為:使用 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 在編譯時自動把型別資訊寫入Reflectmetadata,為依賴注入、驗證、ORM、API 文件等功能提供基礎。- 兩者 配合
reflect-metadata使用,才能在執行階段取得設計時的類別、屬性、參數與回傳型別。 - 在實務開發中,只在需要型別資訊的裝飾器上開啟
emitDecoratorMetadata,其餘情況保持關閉,可減少產出檔案大小與編譯時間。 - 最後,遵守裝飾器執行順序、編寫單元測試、並在
tsconfig.json中明確設定,是避免常見陷阱、打造可維護大型 TypeScript 專案的關鍵。
透過本文的概念講解與實作範例,你已經掌握了如何正確設定與使用 experimentalDecorators 與 emitDecoratorMetadata,接下來可以在自己的專案中實作 DI、驗證或自訂 AOP,讓程式碼更具可讀性、可測試性與可擴充性。祝開發順利! 🚀