日本語

TypeScriptデコレーターの力を探求し、メタデータプログラミング、アスペクト指向、宣言的パターンでコードを強化する方法を解説。世界の開発者向け総合ガイド。

TypeScriptデコレーター:堅牢なアプリケーションのためのメタデータプログラミングパターンの習得

現代の広大なソフトウェア開発の世界において、クリーンでスケーラブル、かつ管理しやすいコードベースを維持することは最も重要です。TypeScriptは、その強力な型システムと高度な機能により、開発者にこれを達成するためのツールを提供します。その中でも最も興味深く革新的な機能の一つがデコレーターです。本稿執筆時点ではまだ実験的な機能(ECMAScriptのステージ3提案)ですが、デコレーターはAngularやTypeORMのようなフレームワークで広く使用されており、私たちがデザインパターン、メタデータプログラミング、アスペクト指向プログラミング(AOP)に取り組む方法を根本的に変えています。

この総合ガイドでは、TypeScriptデコレーターを深く掘り下げ、その仕組み、様々な種類、実践的な応用、そしてベストプラクティスを探求します。大規模なエンタープライズアプリケーション、マイクロサービス、またはクライアントサイドのWebインターフェースを構築しているかどうかにかかわらず、デコレーターを理解することで、より宣言的で保守性が高く、強力なTypeScriptコードを書くことができるようになります。

中核概念の理解:デコレーターとは何か?

本質的に、デコレーターとは、クラス宣言、メソッド、アクセサー、プロパティ、またはパラメーターに付与できる特殊な種類の宣言です。デコレーターは、装飾対象の新しい値を返す(または既存の値を変更する)関数です。その主な目的は、基盤となるコード構造を直接変更することなく、付与された宣言にメタデータを追加したり、その動作を変更したりすることです。このようにコードを外部から宣言的に拡張する方法は、非常に強力です。

デコレーターを、コードの一部に適用する注釈やラベルと考えてください。これらのラベルは、アプリケーションの他の部分やフレームワークによって、しばしば実行時に読み取られたり、作用されたりして、追加の機能や設定を提供します。

デコレーターの構文

デコレーターは、@記号で始まり、その後にデコレーター関数名が続きます。装飾する宣言の直前に配置されます。

@MyDecorator
class MyClass {
  @AnotherDecorator
  myMethod() {
    // ...
  }
}

TypeScriptでデコレーターを有効にする

デコレーターを使用する前に、tsconfig.jsonファイルでexperimentalDecoratorsコンパイラオプションを有効にする必要があります。さらに、高度なメタデータリフレクション機能(フレームワークでよく使用される)のためには、emitDecoratorMetadatareflect-metadataポリフィルも必要になります。

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2017",
    "module": "commonjs",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

また、reflect-metadataをインストールする必要があります:

npm install reflect-metadata --save
# or
yarn add reflect-metadata

そして、アプリケーションのエントリーポイント(例:main.tsapp.ts)の最上部でインポートします:

import "reflect-metadata";
// Your application code follows

デコレーターファクトリ:手軽なカスタマイズ

基本的なデコレーターは関数ですが、その動作を設定するためにデコレーターに引数を渡す必要があることがよくあります。これはデコレーターファクトリを使用することで実現されます。デコレーターファクトリとは、実際のデコレーター関数を返す関数です。デコレーターファクトリを適用する際には、引数を指定して呼び出し、それが返すデコレーター関数をTypeScriptがコードに適用します。

簡単なデコレーターファクトリの例を作成する

異なる接頭辞でメッセージをログに記録できるLoggerデコレーターのファクトリを作成してみましょう。

function Logger(prefix: string) {
  return function (target: Function) {
    console.log(`[${prefix}] Class ${target.name} has been defined.`);
  };
}

@Logger("APP_INIT")
class ApplicationBootstrap {
  constructor() {
    console.log("Application is starting...");
  }
}

const app = new ApplicationBootstrap();
// Output:
// [APP_INIT] Class ApplicationBootstrap has been defined.
// Application is starting...

この例では、Logger("APP_INIT")がデコレーターファクトリの呼び出しです。これは、引数としてtarget: Function(クラスコンストラクタ)を取る実際のデコレーター関数を返します。これにより、デコレーターの動作を動的に設定できます。

TypeScriptにおけるデコレーターの種類

TypeScriptは5つの異なる種類のデコレーターをサポートしており、それぞれが特定の種類の宣言に適用可能です。デコレーター関数のシグネチャは、適用されるコンテキストによって異なります。

1. クラスデコレーター

クラスデコレーターはクラス宣言に適用されます。デコレーター関数は、クラスのコンストラクタを唯一の引数として受け取ります。クラスデコレーターは、クラス定義を監視、変更、あるいは置換することもできます。

シグネチャ:

function ClassDecorator(target: Function) { ... }

戻り値:

クラスデコレーターが値を返した場合、それはクラス宣言を提供されたコンストラクタ関数で置き換えます。これは、ミックスインやクラスの拡張によく使用される強力な機能です。値が返されない場合は、元のクラスが使用されます。

使用例:

クラスデコレーターの例:サービスの注入

クラスを「注入可能」としてマークし、オプションでコンテナ内での名前を提供する、簡単な依存性注入のシナリオを想像してみてください。

const InjectableServiceRegistry = new Map<string, Function>();

function Injectable(name?: string) {
  return function<T extends { new(...args: any[]): {} }>(constructor: T) {
    const serviceName = name || constructor.name;
    InjectableServiceRegistry.set(serviceName, constructor);
    console.log(`Registered service: ${serviceName}`);

    // Optionally, you could return a new class here to augment behavior
    return class extends constructor {
      createdAt = new Date();
      // Additional properties or methods for all injected services
    };
  };
}

@Injectable("UserService")
class UserDataService {
  getUsers() {
    return [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];
  }
}

@Injectable()
class ProductDataService {
  getProducts() {
    return [{ id: 101, name: "Laptop" }, { id: 102, name: "Mouse" }];
  }
}

console.log("--- Services Registered ---");
console.log(Array.from(InjectableServiceRegistry.keys()));

const userServiceConstructor = InjectableServiceRegistry.get("UserService");
if (userServiceConstructor) {
  const userServiceInstance = new userServiceConstructor();
  console.log("Users:", userServiceInstance.getUsers());
  // console.log("User Service Created At:", userServiceInstance.createdAt); // If the returned class is used
}

この例は、クラスデコレーターがクラスを登録し、そのコンストラクタを変更することさえできる方法を示しています。Injectableデコレーターは、理論上の依存性注入システムによってクラスが発見可能になるようにします。

2. メソッドデコレーター

メソッドデコレーターはメソッド宣言に適用されます。これらは3つの引数を受け取ります:ターゲットオブジェクト(静的メンバーの場合はコンストラクタ関数、インスタンスメンバーの場合はクラスのプロトタイプ)、メソッド名、そしてメソッドのプロパティディスクリプタです。

シグネチャ:

function MethodDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }

戻り値:

メソッドデコレーターは新しいPropertyDescriptorを返すことができます。もし返された場合、このディスクリプタがメソッドの定義に使用されます。これにより、元のメソッドの実装を変更または置換することができ、AOPにとって非常に強力です。

使用例:

メソッドデコレーターの例:パフォーマンス監視

メソッドの実行時間をログに記録するためのMeasurePerformanceデコレーターを作成してみましょう。

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

  descriptor.value = function(...args: any[]) {
    const start = process.hrtime.bigint();
    const result = originalMethod.apply(this, args);
    const end = process.hrtime.bigint();
    const duration = Number(end - start) / 1_000_000;
    console.log(`Method "${propertyKey}" executed in ${duration.toFixed(2)} ms`);
    return result;
  };

  return descriptor;
}

class DataProcessor {
  @MeasurePerformance
  processData(data: number[]): number[] {
    // Simulate a complex, time-consuming operation
    for (let i = 0; i < 1_000_000; i++) {
      Math.sin(i);
    }
    return data.map(n => n * 2);
  }

  @MeasurePerformance
  fetchRemoteData(id: string): Promise<string> {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve(`Data for ID: ${id}`);
      }, 500);
    });
  }
}

const processor = new DataProcessor();
processor.processData([1, 2, 3]);
processor.fetchRemoteData("abc").then(result => console.log(result));

MeasurePerformanceデコレーターは、元のメソッドをタイミングロジックでラップし、メソッド内のビジネスロジックを煩雑にすることなく実行時間を表示します。これはアスペクト指向プログラミング(AOP)の典型的な例です。

3. アクセサーデコレーター

アクセサーデコレーターはアクセサー(getおよびset)宣言に適用されます。メソッドデコレーターと同様に、ターゲットオブジェクト、アクセサー名、およびそのプロパティディスクリプタを受け取ります。

シグネチャ:

function AccessorDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }

戻り値:

アクセサーデコレーターは新しいPropertyDescriptorを返すことができ、それがアクセサーの定義に使用されます。

使用例:

アクセサーデコレーターの例:ゲッターのキャッシング

コストの高いゲッター計算の結果をキャッシュするデコレーターを作成してみましょう。

function CachedGetter(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalGetter = descriptor.get;
  const cacheKey = `_cached_${String(propertyKey)}`;

  if (originalGetter) {
    descriptor.get = function() {
      if (this[cacheKey] === undefined) {
        console.log(`[Cache Miss] Computing value for ${String(propertyKey)}`);
        this[cacheKey] = originalGetter.apply(this);
      } else {
        console.log(`[Cache Hit] Using cached value for ${String(propertyKey)}`);
      }
      return this[cacheKey];
    };
  }
  return descriptor;
}

class ReportGenerator {
  private data: number[];

  constructor(data: number[]) {
    this.data = data;
  }

  // Simulates an expensive computation
  @CachedGetter
  get expensiveSummary(): number {
    console.log("Performing expensive summary calculation...");
    return this.data.reduce((sum, current) => sum + current, 0) / this.data.length;
  }
}

const generator = new ReportGenerator([10, 20, 30, 40, 50]);

console.log("First access:", generator.expensiveSummary);
console.log("Second access:", generator.expensiveSummary);
console.log("Third access:", generator.expensiveSummary);

このデコレーターは、expensiveSummaryゲッターの計算が一度だけ実行されることを保証し、後続の呼び出しはキャッシュされた値を返します。このパターンは、プロパティアクセスに重い計算や外部呼び出しが伴う場合のパフォーマンス最適化に非常に役立ちます。

4. プロパティデコレーター

プロパティデコレーターはプロパティ宣言に適用されます。これらは2つの引数を受け取ります:ターゲットオブジェクト(静的メンバーの場合はコンストラクタ関数、インスタンスメンバーの場合はクラスのプロトタイプ)、およびプロパティ名です。

シグネチャ:

function PropertyDecorator(target: Object, propertyKey: string | symbol) { ... }

戻り値:

プロパティデコレーターは値を返すことはできません。その主な用途は、プロパティに関するメタデータを登録することです。プロパティデコレーターが実行される時点ではプロパティのディスクリプタがまだ完全に定義されていないため、プロパティの値やそのディスクリプタを直接変更することはできません。

使用例:

プロパティデコレーターの例:必須フィールドのバリデーション

プロパティを「必須」としてマークし、実行時にそれをバリデーションするデコレーターを作成してみましょう。

interface ValidationRule {
  property: string | symbol;
  validate: (value: any) => boolean;
  message: string;
}

const validationRules: Map<Function, ValidationRule[]> = new Map();

function Required(target: Object, propertyKey: string | symbol) {
  const rules = validationRules.get(target.constructor) || [];
  rules.push({
    property: propertyKey,
    validate: (value: any) => value !== null && value !== undefined && value !== "",
    message: `${String(propertyKey)} is required.`
  });
  validationRules.set(target.constructor, rules);
}

function validate(instance: any): string[] {
  const classRules = validationRules.get(instance.constructor) || [];
  const errors: string[] = [];

  for (const rule of classRules) {
    if (!rule.validate(instance[rule.property])) {
      errors.push(rule.message);
    }
  }
  return errors;
}

class UserProfile {
  @Required
  firstName: string;

  @Required
  lastName: string;

  age?: number;

  constructor(firstName: string, lastName: string, age?: number) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.age = age;
  }
}

const user1 = new UserProfile("John", "Doe", 30);
console.log("User 1 validation errors:", validate(user1)); // []

const user2 = new UserProfile("", "Smith");
console.log("User 2 validation errors:", validate(user2)); // ["firstName is required."]

const user3 = new UserProfile("Alice", "");
console.log("User 3 validation errors:", validate(user3)); // ["lastName is required."]

Requiredデコレーターは、中央のvalidationRulesマップにバリデーションルールを登録するだけです。別のvalidate関数が、このメタデータを使用して実行時にインスタンスをチェックします。このパターンは、バリデーションロジックをデータ定義から分離し、再利用可能でクリーンにします。

5. パラメーターデコレーター

パラメーターデコレーターは、クラスコンストラクタまたはメソッド内のパラメーターに適用されます。これらは3つの引数を受け取ります:ターゲットオブジェクト(静的メンバーの場合はコンストラクタ関数、インスタンスメンバーの場合はクラスのプロトタイプ)、メソッド名(コンストラクタパラメーターの場合はundefined)、および関数パラメーターリスト内でのパラメーターの順序インデックスです。

シグネチャ:

function ParameterDecorator(target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) { ... }

戻り値:

パラメーターデコレーターは値を返すことはできません。プロパティデコレーターと同様に、その主な役割はパラメーターに関するメタデータを追加することです。

使用例:

パラメーターデコレーターの例:リクエストデータの注入

Webフレームワークがパラメーターデコレーターを使用して、リクエストからユーザーIDなどの特定のデータをメソッドパラメーターに注入する方法をシミュレートしてみましょう。

interface ParameterMetadata {
  index: number;
  key: string | symbol;
  resolver: (request: any) => any;
}

const parameterResolvers: Map<Function, Map<string | symbol, ParameterMetadata[]>> = new Map();

function RequestParam(paramName: string) {
  return function (target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) {
    const targetKey = propertyKey || "constructor";
    let methodResolvers = parameterResolvers.get(target.constructor);
    if (!methodResolvers) {
      methodResolvers = new Map();
      parameterResolvers.set(target.constructor, methodResolvers);
    }
    const paramMetadata = methodResolvers.get(targetKey) || [];
    paramMetadata.push({
      index: parameterIndex,
      key: targetKey,
      resolver: (request: any) => request[paramName]
    });
    methodResolvers.set(targetKey, paramMetadata);
  };
}

// A hypothetical framework function to invoke a method with resolved parameters
function executeWithParams(instance: any, methodName: string, request: any) {
  const classResolvers = parameterResolvers.get(instance.constructor);
  if (!classResolvers) {
    return (instance[methodName] as Function).apply(instance, []);
  }
  const methodParamMetadata = classResolvers.get(methodName);
  if (!methodParamMetadata) {
    return (instance[methodName] as Function).apply(instance, []);
  }

  const args: any[] = Array(methodParamMetadata.length);
  for (const meta of methodParamMetadata) {
    args[meta.index] = meta.resolver(request);
  }
  return (instance[methodName] as Function).apply(instance, args);
}

class UserController {
  getUser(@RequestParam("id") userId: string, @RequestParam("token") authToken?: string) {
    console.log(`Fetching user with ID: ${userId}, Token: ${authToken || "N/A"}`);
    return { id: userId, name: "Jane Doe" };
  }

  deleteUser(@RequestParam("id") userId: string) {
    console.log(`Deleting user with ID: ${userId}`);
    return { status: "deleted", id: userId };
  }
}

const userController = new UserController();

// Simulate an incoming request
const mockRequest = {
  id: "user123",
  token: "abc-123",
  someOtherProp: "xyz"
};

console.log("\n--- Executing getUser ---");
executeWithParams(userController, "getUser", mockRequest);

console.log("\n--- Executing deleteUser ---");
executeWithParams(userController, "deleteUser", { id: "user456" });

この例は、パラメーターデコレーターが必須のメソッドパラメーターに関する情報を収集する方法を示しています。フレームワークは、この収集されたメタデータを使用して、メソッドが呼び出されたときに適切な値を自動的に解決して注入することができ、コントローラーやサービスのロジックを大幅に簡素化します。

デコレーターの合成と実行順序

デコレーターは様々な組み合わせで適用でき、その実行順序を理解することは、動作を予測し、予期しない問題を回避するために不可欠です。

単一ターゲットへの複数デコレーター

単一の宣言(クラス、メソッド、プロパティなど)に複数のデコレーターが適用される場合、それらは特定の順序で実行されます:評価のためには下から上へ、または右から左へ。しかし、その結果は逆の順序で適用されます。

@DecoratorA
@DecoratorB
class MyClass {
  // ...
}

ここでは、DecoratorBが最初に評価され、次にDecoratorAが評価されます。もしそれらがクラスを変更する場合(例:新しいコンストラクタを返す)、DecoratorAによる変更がDecoratorBによる変更をラップまたは上書きします。

例:メソッドデコレーターの連鎖

LogCallAuthorizationという2つのメソッドデコレーターを考えてみましょう。

function LogCall(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`[LOG] Calling ${String(propertyKey)} with args:`, args);
    const result = originalMethod.apply(this, args);
    console.log(`[LOG] Method ${String(propertyKey)} returned:`, result);
    return result;
  };
  return descriptor;
}

function Authorization(roles: string[]) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
      const currentUserRoles = ["admin"]; // Simulate fetching current user roles
      const authorized = roles.some(role => currentUserRoles.includes(role));
      if (!authorized) {
        console.warn(`[AUTH] Access denied for ${String(propertyKey)}. Required roles: ${roles.join(", ")}`);
        throw new Error("Unauthorized access");
      }
      console.log(`[AUTH] Access granted for ${String(propertyKey)}`);
      return originalMethod.apply(this, args);
    };
    return descriptor;
  };
}

class SecureService {
  @LogCall
  @Authorization(["admin"])
  deleteSensitiveData(id: string) {
    console.log(`Deleting sensitive data for ID: ${id}`);
    return `Data ID ${id} deleted.`;
  }

  @Authorization(["user"])
  @LogCall // Order changed here
  fetchPublicData(query: string) {
    console.log(`Fetching public data with query: ${query}`);
    return `Public data for query: ${query}`; 
  }
}

const service = new SecureService();

try {
  console.log("\n--- Calling deleteSensitiveData (Admin User) ---");
  service.deleteSensitiveData("record123");
} catch (error: any) {
  console.error(error.message);
}

try {
  console.log("\n--- Calling fetchPublicData (Non-Admin User) ---");
  // Simulate a non-admin user trying to access fetchPublicData which requires 'user' role
  const mockUserRoles = ["guest"]; // This will fail auth
  // To make this dynamic, you'd need a DI system or static context for current user roles.
  // For simplicity, we assume the Authorization decorator has access to current user context.
  // Let's adjust Authorization decorator to always assume 'admin' for demo purposes, 
  // so the first call succeeds and second fails to show different paths.
  
  // Re-run with user role for fetchPublicData to succeed.
  // Imagine currentUserRoles in Authorization becomes: ['user']
  // For this example, let's keep it simple and show the order effect.
  service.fetchPublicData("search term"); // This will execute Auth -> Log
} catch (error: any) {
  console.error(error.message);
}

/* deleteSensitiveDataの期待される出力:
[AUTH] Access granted for deleteSensitiveData
[LOG] Calling deleteSensitiveData with args: [ 'record123' ]
Deleting sensitive data for ID: record123
[LOG] Method deleteSensitiveData returned: Data ID record123 deleted.
*/

/* fetchPublicDataの期待される出力 (ユーザーが'user'ロールを持つ場合):
[LOG] Calling fetchPublicData with args: [ 'search term' ]
[AUTH] Access granted for fetchPublicData
Fetching public data with query: search term
[LOG] Method fetchPublicData returned: Public data for query: search term
*/

順序に注意してください:deleteSensitiveDataでは、Authorization(下)が最初に実行され、次にLogCall(上)がそれをラップします。Authorizationの内部ロジックが最初に実行されます。fetchPublicDataでは、LogCall(下)が最初に実行され、次にAuthorization(上)がそれをラップします。これは、LogCallアスペクトがAuthorizationアスペクトの外側になることを意味します。この違いは、ロギングやエラーハンドリングのような横断的関心事にとって重要であり、実行順序が動作に大きく影響する可能性があります。

異なるターゲットの実行順序

クラス、そのメンバー、およびパラメーターすべてにデコレーターがある場合、実行順序は明確に定義されています:

  1. パラメーターデコレーターが最初に適用され、各パラメーターについて、最後のパラメーターから最初のパラメーターへと進みます。
  2. 次に、各メンバーに対してメソッド、アクセサー、またはプロパティデコレーターが適用されます。
  3. 最後に、クラスデコレーターがクラス自体に適用されます。

各カテゴリ内で、同じターゲット上の複数のデコレーターは下から上へ(または右から左へ)適用されます。

例:完全な実行順序

function log(message: string) {
  return function (target: any, propertyKey: string | symbol | undefined, descriptorOrIndex?: PropertyDescriptor | number) {
    if (typeof descriptorOrIndex === 'number') {
      console.log(`Param Decorator: ${message} on parameter #${descriptorOrIndex} of ${String(propertyKey || "constructor")}`);
    } else if (typeof propertyKey === 'string' || typeof propertyKey === 'symbol') {
      if (descriptorOrIndex && 'value' in descriptorOrIndex && typeof descriptorOrIndex.value === 'function') {
        console.log(`Method/Accessor Decorator: ${message} on ${String(propertyKey)}`);
      } else {
        console.log(`Property Decorator: ${message} on ${String(propertyKey)}`);
      }
    } else {
      console.log(`Class Decorator: ${message} on ${target.name}`);
    }
    return descriptorOrIndex; // Return descriptor for method/accessor, undefined for others
  };
}

@log("Class Level D")
@log("Class Level C")
class MyDecoratedClass {
  @log("Static Property A")
  static staticProp: string = "";

  @log("Instance Property B")
  instanceProp: number = 0;

  @log("Method D")
  @log("Method C")
  myMethod(
    @log("Parameter Z") paramZ: string,
    @log("Parameter Y") paramY: number
  ) {
    console.log("Method myMethod executed.");
  }

  @log("Getter/Setter F")
  get myAccessor() {
    return "";
  }

  set myAccessor(value: string) {
    //...
  }

  constructor() {
    console.log("Constructor executed.");
  }
}

new MyDecoratedClass();
// Call method to trigger method decorator
new MyDecoratedClass().myMethod("hello", 123);

/* 予測される出力順序(特定のTypeScriptバージョンとコンパイルに依存しておおよそ):
Param Decorator: Parameter Y on parameter #1 of myMethod
Param Decorator: Parameter Z on parameter #0 of myMethod
Property Decorator: Static Property A on staticProp
Property Decorator: Instance Property B on instanceProp
Method/Accessor Decorator: Getter/Setter F on myAccessor
Method/Accessor Decorator: Method C on myMethod
Method/Accessor Decorator: Method D on myMethod
Class Decorator: Class Level C on MyDecoratedClass
Class Decorator: Class Level D on MyDecoratedClass
Constructor executed.
Method myMethod executed.
*/

コンストラクタやメソッドがいつ呼び出されるかによって、実際のコンソールログのタイミングは若干異なる場合がありますが、デコレーター関数自体が実行される順序(そしてその副作用や返される値が適用される順序)は上記のルールに従います。

デコレーターによる実践的な応用とデザインパターン

デコレーターは、特にreflect-metadataポリフィルと組み合わせることで、メタデータ駆動プログラミングの新しい領域を開きます。これにより、定型的なコードや横断的関心事を抽象化する強力なデザインパターンが可能になります。

1. 依存性注入(DI)

デコレーターの最も顕著な用途の一つは、依存性注入フレームワーク(Angularの@Injectable()@Component()など、またはNestJSの広範なDI利用)です。デコレーターを使用すると、コンストラクタやプロパティに直接依存関係を宣言でき、フレームワークが正しいサービスを自動的にインスタンス化して提供できるようになります。

例:簡略化されたサービス注入

import "reflect-metadata"; // Essential for emitDecoratorMetadata

const INJECTABLE_METADATA_KEY = Symbol("injectable");
const INJECT_METADATA_KEY = Symbol("inject");

function Injectable() {
  return function (target: Function) {
    Reflect.defineMetadata(INJECTABLE_METADATA_KEY, true, target);
  };
}

function Inject(token: any) {
  return function (target: Object, propertyKey: string | symbol, parameterIndex: number) {
    const existingInjections: any[] = Reflect.getOwnMetadata(INJECT_METADATA_KEY, target, propertyKey) || [];
    existingInjections[parameterIndex] = token;
    Reflect.defineMetadata(INJECT_METADATA_KEY, existingInjections, target, propertyKey);
  };
}

class Container {
  private static instances = new Map<any, any>();

  static resolve<T>(target: { new (...args: any[]): T }): T {
    if (Container.instances.has(target)) {
      return Container.instances.get(target);
    }

    const isInjectable = Reflect.getMetadata(INJECTABLE_METADATA_KEY, target);
    if (!isInjectable) {
      throw new Error(`Class ${target.name} is not marked as @Injectable.`);
    }

    // Get constructor parameters' types (requires emitDecoratorMetadata)
    const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target) || [];
    const explicitInjections: any[] = Reflect.getMetadata(INJECT_METADATA_KEY, target) || [];

    const dependencies = paramTypes.map((paramType, index) => {
      // Use explicit @Inject token if provided, otherwise infer type
      const token = explicitInjections[index] || paramType;
      if (token === undefined) {
        throw new Error(`Cannot resolve parameter at index ${index} for ${target.name}. It might be a circular dependency or primitive type without explicit @Inject.`);
      }
      return Container.resolve(token);
    });

    const instance = new target(...dependencies);
    Container.instances.set(target, instance);
    return instance;
  }
}

// Define services
@Injectable()
class DatabaseService {
  connect() {
    console.log("Connecting to database...");
    return "DB Connection";
  }
}

@Injectable()
class AuthService {
  private db: DatabaseService;

  constructor(db: DatabaseService) {
    this.db = db;
  }

  login() {
    console.log(`AuthService: Authenticating using ${this.db.connect()}`);
    return "User logged in";
  }
}

@Injectable()
class UserService {
  private authService: AuthService;
  private dbService: DatabaseService; // Example of injecting via property using a custom decorator or framework feature

  constructor(@Inject(AuthService) authService: AuthService,
              @Inject(DatabaseService) dbService: DatabaseService) {
    this.authService = authService;
    this.dbService = dbService;
  }

  getUserProfile() {
    this.authService.login();
    this.dbService.connect();
    console.log("UserService: Fetching user profile...");
    return { id: 1, name: "Global User" };
  }
}

// Resolve the main service
console.log("--- Resolving UserService ---");
const userService = Container.resolve(UserService);
console.log(userService.getUserProfile());

console.log("\n--- Resolving AuthService (should be cached) ---");
const authService = Container.resolve(AuthService);
authService.login();

この詳細な例は、@Injectable@Injectデコレーターがreflect-metadataと組み合わさることで、カスタムのContainerが依存関係を自動的に解決し提供する方法を示しています。TypeScriptが(emitDecoratorMetadataがtrueの場合に)自動的に出力するdesign:paramtypesメタデータがここでは非常に重要です。

2. アスペクト指向プログラミング(AOP)

AOPは、複数のクラスやモジュールにまたがる横断的関心事(例:ロギング、セキュリティ、トランザクション)をモジュール化することに焦点を当てています。デコレーターは、TypeScriptでAOPの概念を実装するのに非常に適しています。

例:メソッドデコレーターによるロギング

LogCallデコレーターを再訪すると、これはAOPの完璧な例です。メソッドの元のコードを変更することなく、任意のメソッドにロギング動作を追加します。これにより、「何をすべきか」(ビジネスロジック)と「それをどのように行うか」(ロギング、パフォーマンス監視など)が分離されます。

function LogMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`[LOG AOP] Entering method: ${String(propertyKey)} with args:`, args);
    try {
      const result = originalMethod.apply(this, args);
      console.log(`[LOG AOP] Exiting method: ${String(propertyKey)} with result:`, result);
      return result;
    } catch (error: any) {
      console.error(`[LOG AOP] Error in method ${String(propertyKey)}:`, error.message);
      throw error;
    }
  };
  return descriptor;
}

class PaymentProcessor {
  @LogMethod
  processPayment(amount: number, currency: string) {
    if (amount <= 0) {
      throw new Error("Payment amount must be positive.");
    }
    console.log(`Processing payment of ${amount} ${currency}...`);
    return `Payment of ${amount} ${currency} processed successfully.`;
  }

  @LogMethod
  refundPayment(transactionId: string) {
    console.log(`Refunding payment for transaction ID: ${transactionId}...`);
    return `Refund initiated for ${transactionId}.`;
  }
}

const processor = new PaymentProcessor();
processor.processPayment(100, "USD");
try {
  processor.processPayment(-50, "EUR");
} catch (error: any) {
  console.error("Caught error:", error.message);
}

このアプローチにより、PaymentProcessorクラスは支払いロジックに純粋に集中でき、LogMethodデコレーターがロギングという横断的関心事を処理します。

3. バリデーションと変換

デコレーターは、プロパティに直接バリデーションルールを定義したり、シリアライズ/デシリアライズ中にデータを変換したりするのに非常に役立ちます。

例:プロパティデコレーターによるデータバリデーション

先ほどの@Requiredの例はすでにこれを示しています。ここでは、数値範囲のバリデーションの別の例を示します。

interface FieldValidationRule {
  property: string | symbol;
  validator: (value: any) => boolean;
  message: string;
}

const fieldValidationRules = new Map<Function, FieldValidationRule[]>();

function addValidationRule(target: Object, propertyKey: string | symbol, validator: (value: any) => boolean, message: string) {
  const rules = fieldValidationRules.get(target.constructor) || [];
  rules.push({ property: propertyKey, validator, message });
  fieldValidationRules.set(target.constructor, rules);
}

function IsPositive(target: Object, propertyKey: string | symbol) {
  addValidationRule(target, propertyKey, (value: number) => value > 0, `${String(propertyKey)} must be a positive number.`);
}

function MaxLength(maxLength: number) {
  return function (target: Object, propertyKey: string | symbol) {
    addValidationRule(target, propertyKey, (value: string) => value.length <= maxLength, `${String(propertyKey)} must be at most ${maxLength} characters long.`);
  };
}

class Product {
  @MaxLength(50)
  name: string;

  @IsPositive
  price: number;

  constructor(name: string, price: number) {
    this.name = name;
    this.price = price;
  }

  static validate(instance: any): string[] {
    const errors: string[] = [];
    const rules = fieldValidationRules.get(instance.constructor) || [];
    for (const rule of rules) {
      if (!rule.validator(instance[rule.property])) {
        errors.push(rule.message);
      }
    }
    return errors;
  }
}

const product1 = new Product("Laptop", 1200);
console.log("Product 1 errors:", Product.validate(product1)); // []

const product2 = new Product("Very long product name that exceeds fifty characters limit for testing purpose", 50);
console.log("Product 2 errors:", Product.validate(product2)); // ["name must be at most 50 characters long."]

const product3 = new Product("Book", -10);
console.log("Product 3 errors:", Product.validate(product3)); // ["price must be a positive number."]

この設定により、モデルのプロパティにバリデーションルールを宣言的に定義でき、データモデルがその制約に関して自己記述的になります。

ベストプラクティスと考慮事項

デコレーターは強力ですが、慎重に使用する必要があります。誤用すると、デバッグや理解が困難なコードになる可能性があります。

いつデコレーターを使用すべきか(そして使用すべきでないか)

パフォーマンスへの影響

デコレーターはコンパイル時(またはトランスパイルされた場合はJavaScriptランタイムの定義時)に実行されます。変換やメタデータの収集は、クラス/メソッドが定義されるときに行われ、呼び出しごとではありません。したがって、デコレーターを*適用する*ことによるランタイムパフォーマンスへの影響は最小限です。しかし、デコレーター*内部のロジック*は、特にメソッド呼び出しごとに高価な操作(例:メソッドデコレーター内の複雑な計算)を行う場合、パフォーマンスに影響を与える可能性があります。

保守性と可読性

デコレーターは、正しく使用されると、定型的なコードを主要なロジックから移動させることで、可読性を大幅に向上させることができます。しかし、複雑で隠された変換を行う場合、デバッグが困難になることがあります。デコレーターが十分に文書化され、その動作が予測可能であることを確認してください。

実験的ステータスとデコレーターの未来

TypeScriptのデコレーターがステージ3のTC39提案に基づいていることを再度強調することが重要です。これは、仕様がほぼ安定しているが、公式のECMAScript標準の一部となる前にまだマイナーな変更が行われる可能性があることを意味します。Angularのようなフレームワークは、最終的な標準化を見越してこれらを受け入れています。これは一定のリスクを伴いますが、その広範な採用を考えると、重大な破壊的変更は考えにくいです。

TC39提案は進化しています。TypeScriptの現在の実装は、提案の古いバージョンに基づいています。「レガシーデコレーター」と「標準デコレーター」の区別があります。公式の標準が確定すると、TypeScriptはその実装を更新する可能性があります。フレームワークを使用しているほとんどの開発者にとって、この移行はフレームワーク自体によって管理されます。ライブラリの作者にとっては、レガシーと将来の標準デコレーターの間の微妙な違いを理解することが必要になるかもしれません。

emitDecoratorMetadataコンパイラオプション

このオプションをtsconfig.jsontrueに設定すると、TypeScriptコンパイラは特定の設計時型メタデータをコンパイルされたJavaScriptに出力するように指示します。このメタデータには、コンストラクタパラメーターの型(design:paramtypes)、メソッドの戻り値の型(design:returntype)、およびプロパティの型(design:type)が含まれます。

この出力されたメタデータは、標準のJavaScriptランタイムの一部ではありません。通常、reflect-metadataポリフィルによって消費され、Reflect.getMetadata()関数を介してアクセス可能になります。これは、依存性注入のような高度なパターンにとって絶対に不可欠であり、コンテナが明示的な設定なしにクラスが必要とする依存関係の型を知る必要があります。

デコレーターによる高度なパターン

デコレーターは、さらに洗練されたパターンを構築するために組み合わせたり拡張したりすることができます。

1. デコレーターのデコレーション(高階デコレーター)

他のデコレーターを変更または合成するデコレーターを作成することができます。これはあまり一般的ではありませんが、デコレーターの関数的な性質を示しています。

// A decorator that ensures a method is logged and also requires admin roles
function AdminAndLoggedMethod() {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    // Apply Authorization first (inner)
    Authorization(["admin"])(target, propertyKey, descriptor);
    // Then apply LogCall (outer)
    LogCall(target, propertyKey, descriptor);

    return descriptor; // Return the modified descriptor
  };
}

class AdminPanel {
  @AdminAndLoggedMethod()
  deleteUserAccount(userId: string) {
    console.log(`Deleting user account: ${userId}`);
    return `User ${userId} deleted.`;
  }
}

const adminPanel = new AdminPanel();
adminPanel.deleteUserAccount("user007");
/* Expected Output (assuming admin role):
[AUTH] Access granted for deleteUserAccount
[LOG] Calling deleteUserAccount with args: [ 'user007' ]
Deleting user account: user007
[LOG] Method deleteUserAccount returned: User user007 deleted.
*/

ここで、AdminAndLoggedMethodはデコレーターを返すファクトリであり、そのデコレーター内部で他の2つのデコレーターを適用しています。このパターンは、複雑なデコレーターの合成をカプセル化することができます。

2. ミックスインのためのデコレーターの使用

TypeScriptにはミックスインを実装する他の方法がありますが、デコレーターを使用してクラスに機能を宣言的に注入することができます。

function ApplyMixins(constructors: Function[]) {
  return function (derivedConstructor: Function) {
    constructors.forEach(baseConstructor => {
      Object.getOwnPropertyNames(baseConstructor.prototype).forEach(name => {
        Object.defineProperty(
          derivedConstructor.prototype,
          name,
          Object.getOwnPropertyDescriptor(baseConstructor.prototype, name) || Object.create(null)
        );
      });
    });
  };
}

class Disposable {
  isDisposed: boolean = false;
  dispose() {
    this.isDisposed = true;
    console.log("Object disposed.");
  }
}

class Loggable {
  log(message: string) {
    console.log(`[Loggable] ${message}`);
  }
}

@ApplyMixins([Disposable, Loggable])
class MyResource implements Disposable, Loggable {
  // These properties/methods are injected by the decorator
  isDisposed!: boolean;
  dispose!: () => void;
  log!: (message: string) => void;

  constructor(public name: string) {
    this.log(`Resource ${this.name} created.`);
  }

  cleanUp() {
    this.dispose();
    this.log(`Resource ${this.name} cleaned up.`);
  }
}

const resource = new MyResource("NetworkConnection");
console.log(`Is disposed: ${resource.isDisposed}`);
resource.cleanUp();
console.log(`Is disposed: ${resource.isDisposed}`);

この@ApplyMixinsデコレーターは、基本コンストラクタから派生クラスのプロトタイプにメソッドとプロパティを動的にコピーし、効果的に機能を「ミックスイン」します。

結論:現代のTypeScript開発を強化する

TypeScriptデコレーターは、メタデータ駆動およびアスペクト指向プログラミングの新しいパラダイムを可能にする、強力で表現力豊かな機能です。これにより、開発者はクラス、メソッド、プロパティ、アクセサー、およびパラメーターの中核ロジックを変更することなく、宣言的な動作を強化、変更、追加することができます。この関心事の分離は、よりクリーンで、保守性が高く、非常に再利用可能なコードにつながります。

依存性注入の簡素化や堅牢なバリデーションシステムの実装から、ロギングやパフォーマンス監視のような横断的関心事の追加まで、デコレーターは多くの一般的な開発上の課題に対するエレガントな解決策を提供します。その実験的なステータスは注意が必要ですが、主要なフレームワークでの広範な採用は、その実用的な価値と将来の関連性を示しています。

TypeScriptデコレーターを習得することで、より堅牢で、スケーラブルで、インテリジェントなアプリケーションを構築するための重要なツールを arsenai に加えることができます。責任を持ってそれらを受け入れ、その仕組みを理解し、TypeScriptプロジェクトで新しいレベルの宣言的な力を解き放ちましょう。