TypeScriptで作る:型推論を強化するCommandパターンの設計
ソフトウェア開発において、型安全性と柔軟性を両立させるための設計は重要です。特に、コマンドパターンのような構造では、コマンドの入力と出力の型を正確に推論する仕組みが必要です。本記事では、コマンドのインターフェースにメソッドを追加することで、型推論を強化した方法を紹介します。
背景と課題
前回の記事で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
を正確に推論できるようになります。これにより、コードの簡潔さと型安全性が向上します。 - 実行時の動作には影響しない: このメソッドは型システムのためだけに存在し、実際に呼び出されることはありません。
修正後のコマンドとハンドラー
以下は、修正後のコマンドとコマンドハンドラーの例です。
AddUserCommand
1️⃣ コマンドの例: 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.");
}
}
AddUserCommandHandler
2️⃣ コマンドハンドラーの例: 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);
}
}
UserController
(CommandBus
の利用側)の修正
executeの戻り値をキャストする必要がなくなりました。
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️⃣ 型推論の強化
-
CommandHandler
やCommandBus
での型推論が正確になり、冗長な型指定が不要になります。
2️⃣ コードの簡潔化
-
getOutputType
を用いることで、型安全性を維持しながらコードの可読性が向上します。
3️⃣ 保守性の向上
- 型の不一致が発生しにくくなり、リファクタリングや新しいコマンドの追加が容易になります。
注意点
-
実行時に呼び出さない:
getOutputType
メソッドは型推論のためだけに存在するため、実行時に呼び出すべきではありません。 - 型補助専用の実装: 他のメソッドやプロパティとの混同を避けるよう設計してください。
まとめ
このアプローチにより、コマンドパターンを利用したTypeScriptの設計がさらに洗練され、将来的な機能拡張やメンテナンスが容易になります。また、型推論を強化することで、開発者が意図した通りの型の挙動を得られ、予期しないエラーを未然に防ぐことができます。
特に、infer
を活用した出力型の自動推論により、冗長な型指定が不要となり、コードの簡潔さと保守性が向上します。これにより、柔軟性と堅牢性を備えた設計を実現します。
サンプルコードはこちらのGitHubリポジトリを参照してください:ts-command-handler-example
Discussion