🎯

TypeScriptで作る:型推論を強化するCommandパターンの設計

2025/01/19に公開

ソフトウェア開発において、型安全性と柔軟性を両立させるための設計は重要です。特に、コマンドパターンのような構造では、コマンドの入力と出力の型を正確に推論する仕組みが必要です。本記事では、コマンドのインターフェースにメソッドを追加することで、型推論を強化した方法を紹介します。


背景と課題

前回の記事でCommandパターンの実装方法について紹介しました。しかし、コマンドの実行結果に対してキャストが必要でした。それを解消するため、入力型Iと出力型Oをコマンドごとに明確に定義したところ、型推論が不十分で、コマンドハンドラーで出力型を推論する際に、unknown型にフォールバックするケースがありました。


解決策: getOutputTypeメソッドの追加

この問題を解決するために、ICommandに以下のようなgetOutputTypeメソッドを追加しました。このメソッドは型システムの補助として機能し、型推論を強化します。特に、inferを活用してコマンドの出力型を自動的に推論させる仕組みが特徴です。

ICommandの定義

export interface CommandInput {}

export interface CommandOutput {}

export interface ICommand<I extends CommandInput, O extends CommandOutput> {
  input: I;

  /**
   * 出力型を明示するためのメソッド
   * 実行時には使用せず、型推論を補助するためだけに存在する
   */
  getOutputType(): O;
}

getOutputTypeメソッドの役割とinferによる型推論

  • 型推論の補助: TypeScriptの型推論エンジンが、inferを活用してコマンドの出力型Oを正確に推論できるようになります。これにより、コードの簡潔さと型安全性が向上します。
  • 実行時の動作には影響しない: このメソッドは型システムのためだけに存在し、実際に呼び出されることはありません。

修正後のコマンドとハンドラー

以下は、修正後のコマンドとコマンドハンドラーの例です。

1️⃣ コマンドの例: AddUserCommand

export class AddUserCommand implements CommandInterface<AddUserCommandInput, AddUserCommandOutput> {
  constructor(public readonly input: AddUserCommandInput) {}

  getOutputType(): AddUserCommandOutput {
    throw new Error("This method is for type inference only and should not be called.");
  }
}

2️⃣ コマンドハンドラーの例: AddUserCommandHandler

export class AddUserCommandHandler extends CommandHandler<AddUserCommand> {
  async execute(command: AddUserCommand): Promise<AddUserCommandOutput> {
    const user = await this.userRepository.save(command.input);
    const output = UserDTO.fromDomain(user)
    return {output}  // AddUserCommandOutput: {output: UserDTO}
  }
}

CommandBusの修正

CommandBusでは、登録と実行時に型安全性を維持しつつ、コマンドの出力型を正確に推論できるようにしました。

export class CommandBus {
  private handlers = new Map<string, CommandHandler<ICommand<any, any>>>();

  register<
    C extends ICommand<I, O>,
    I extends CommandInput = C extends ICommand<infer I, any> ? I : never,
    O extends CommandOutput = C extends ICommand<I, infer O> ? O : never
  >(commandType: new (input: I) => C, handler: CommandHandler<C>): void {
    this.handlers.set(commandType.name, handler);
  }

  execute<
    C extends ICommand<I, O>,
    I extends CommandInput = C extends ICommand<infer I, any> ? I : never,
    O extends CommandOutput = C extends ICommand<I, infer O> ? O : never
  >(command: C): O | Promise<O> {
    const handler = this.handlers.get(command.constructor.name) as CommandHandler<C>;
    if (!handler) {
      throw new Error(`Handler not found for command: ${command.constructor.name}`);
    }
    return handler.execute(command);
  }
}

UserControllerCommandBusの利用側)の修正

executeの戻り値をキャストする必要がなくなりました。

typescript
export class UserController {
  constructor(private commandBus: CommandBus) {}

  async addUser(name: string, email: string): Promise<void> {
-    const command = new ListUsersCommand()
-    const users: UserDTO[] = (await this.commandBus.execute(
-      command,
-    )) as UserDTO[]
+    const command = new AddUserCommand({name, email})
+    await this.commandBus.execute(command)
  }

  async listUsers(): Promise<void> {
-    const command = new ListUsersCommand()
-    const users: UserDTO[] = (await this.commandBus.execute(
-      command,
-    )) as UserDTO[]
+    const command = new ListUsersCommand({})
+    const {output: users} = await this.commandBus.execute(command)

    console.log('📄 User List:')
    for (const user of users) {
      console.log(
        `🆔 ID: ${user.id}, 👤 Name: ${user.name}, 📧 Email: ${user.email}`,
      )
    }
  }
}

メリット

1️⃣ 型推論の強化

  • CommandHandlerCommandBusでの型推論が正確になり、冗長な型指定が不要になります。

2️⃣ コードの簡潔化

  • getOutputTypeを用いることで、型安全性を維持しながらコードの可読性が向上します。

3️⃣ 保守性の向上

  • 型の不一致が発生しにくくなり、リファクタリングや新しいコマンドの追加が容易になります。

注意点

  • 実行時に呼び出さない: getOutputTypeメソッドは型推論のためだけに存在するため、実行時に呼び出すべきではありません。
  • 型補助専用の実装: 他のメソッドやプロパティとの混同を避けるよう設計してください。

まとめ

このアプローチにより、コマンドパターンを利用したTypeScriptの設計がさらに洗練され、将来的な機能拡張やメンテナンスが容易になります。また、型推論を強化することで、開発者が意図した通りの型の挙動を得られ、予期しないエラーを未然に防ぐことができます。

特に、inferを活用した出力型の自動推論により、冗長な型指定が不要となり、コードの簡潔さと保守性が向上します。これにより、柔軟性と堅牢性を備えた設計を実現します。

サンプルコードはこちらのGitHubリポジトリを参照してください:ts-command-handler-example

Discussion