意識したのは次のこと。
- このユーティリティを使う側が、ロールバックや変更の取り消し処理を手続き的に書かずに済むようにしたい
- 心的イメージとして「コマンド」と「シーケンス」を採用。コマンドは一つの処理とその取り消し処理。シーケンスはそのコマンドの連なり
というわけで書いてみた
結論
こうなった
/**
* 実行したい処理をrunに、戻し処理をrevertに指定する。
*/
export class RevertibleCommand<T> {
constructor(
public readonly run: () => Promise<T> | T,
public readonly revert: () => Promise<void> | void,
) { }
}
/**
* 例外発生時には戻し処理(revert)がまとめて実行される。
* 正常時は、各コマンドの戻り値をタプルとして返す。
* 返却値の型はコマンドごとに異なっていても問題ない(タプルが個別の型を持つ)。
*/
export class Sequence<Commands extends RevertibleCommand<any>[]> {
constructor(
private readonly commands: readonly [...Commands]
) { }
async run(): Promise<{
[K in keyof Commands]: Commands[K] extends RevertibleCommand<infer R> ? R : never
}> {
const reverts: Array<() => Promise<void> | void> = [];
const results: unknown[] = [];
for (const command of this.commands) {
try {
const result = await command.run();
results.push(result);
reverts.push(command.revert);
} catch (error) {
await Promise.allSettled(reverts.map(fn => fn()));
throw error;
}
}
return results as {
[K in keyof Commands]: Commands[K] extends RevertibleCommand<infer R> ? R : never
};
}
}
使い方
下記のようにして使う。例外発生時には戻し処理(revert)がまとめて実行される。
正常時は、各コマンドの戻り値をタプルとして返す。
返却値の型はコマンドごとに異なっていても問題ない(タプルが個別の型を持つ)。
const command1 = new RevertibleCommand<number>(
() => { console.log('Run A'); return 10; },
() => { console.log('Revert A'); },
);
const command2 = new RevertibleCommand<string>(
() => { console.log('Run B'); return 'OK'; },
() => { console.log('Revert B'); },
);
const command3 = new RevertibleCommand<boolean>(
() => { console.log('Run C'); return true; },
() => { console.log('Revert C'); },
);
const seq = new Sequence([command1, command2, command3]);
(async () => {
const [a, b, c] = await seq.run();
console.log(a, b, c);
})();