非同期コールバックの「隠れた地雷」を型で排除する:Safe Async Executionの設計指針
フロントエンド開発において、非同期処理のハンドリングは避けて通れない道だ。しかし、多くの現場で「なんとなく `try-catch` を書く」「なんとなく `Promise.catch` を繋ぐ」という属人的な運用に依存していないだろうか?
コンパイラの力を使い、「非同期エラーをハンドリングしていないコードは、そもそもビルドすら通さない」という強制力を持たせる。これがTypeScriptを「単なる型定義ツール」ではなく「堅牢なシステムを作るための静的解析エンジン」として使いこなすための第一歩だ。
今回は、コールバック関数に非同期処理を渡す際、呼び出し側に「エラーハンドリングの責任」を強いる高度な設計パターンを伝授する。
—
なぜ「ただの `(args: T) => Promise`」では不十分なのか
まず、アンチパターンを見てほしい。
// 悪い例:エラーハンドリングが呼び出し側に委ねられ、忘れ去られる
type Callback = (data: string) => Promise
async function executeTask(cb: Callback) {
await cb(“payload”); // ここでrejectされたらどうなる?
}
この実装の問題点は明らかだ。呼び出し側が `executeTask(async () => { throw new Error(…) })` と書いたとき、そのエラーがどこで補足されるか、あるいは補足されずにイベントループを汚染するかが型からは一切読み取れない。
コンパイラに「エラーを返せ」と指示するには、戻り値の型を拡張し、成功と失敗を明確に分離する必要がある。
—
実践:Result型を用いた「強制エラーハンドリング」パターン
エラーを「例外」として投げるのではなく、「値」として型システムに取り込む。これには `Result` パターンを用いるのが最も堅牢だ。
// 成功と失敗を表現する共用体型
type Result
| { success: true; value: T }
| { success: false; error: E };
// 非同期コールバックの定義
type SafeAsyncCallback
/
- コールバックの実行を保証するラッパー
/
async function runTask
const result = await cb(arg);
if (!result.success) {
// ここでエラーハンドリングのロジックを共通化できる
console.error(“Task failed:”, result.error);
throw result.error; // 必要に応じて再スロー
}
return result.value;
}
この設計の優位点
1. 静的解析の強化: 呼び出し側は `Promise
2. 型安全なエラー伝播: エラー型を汎用化することで、特定のドメインエラーを型として強制できる。
—
さらに一歩先へ:Promiseを強制的に「ハンドリング済み」にする型
コンパイル時に「`.catch()` を書いたか?」を完全に検知するのは難しいが、型定義で「エラーを返さなければならない」と縛ることは可能だ。
/
- 呼び出し側にエラーハンドリングを強制する型
- 戻り値を Result 型に限定することで、成功・失敗の判定を必須化する
/
type HandledPromise
function processData(
data: string,
onProcess: (d: string) => HandledPromise
) {
// onProcessを実行する際、呼び出し側は必ず success/error を判定しなければならない
return onProcess(data);
}
// 呼び出し側のコード
processData(“input”, async (d) => {
try {
return { success: true, value: d.length };
} catch (e) {
return { success: false, error: e as Error };
}
});
—
パフォーマンスと設計上の注意点
1. オーバーヘッドの最小化: `Result` オブジェクトの生成はメモリを消費するが、Node.jsや現代のブラウザエンジンでは、このような軽量なオブジェクトの割当は最適化の範囲内だ。むしろ、例外のスタックトレース生成コスト(非常に重い)を抑えられるため、頻発するエラーであれば `Result` パターンの方がパフォーマンスが高い。
2. Promise.allとの相性: `Result` パターンにすれば、`Promise.all` を使う際に「一部が失敗したときにどうするか」を型のレベルで制御できる。`.catch()` で飲み込むとすべてが成功扱いになってしまうが、`Result` なら `results.some(r => !r.success)` といった安全なチェックが可能だ。
チーフアーキテクトからの助言
TypeScriptの型システムは、単なるバリデーターではない。「ソフトウェアの設計図をコード化する言語」である。
「例外」はプログラムの流れを非局所的に乱す。もし君が堅牢なライブラリやコンポーネントを設計しているなら、例外を型で包み込み、呼び出し側に「この処理は失敗する可能性がある」と明示的に伝える設計を選択してほしい。
コードの行数は数行増えるかもしれない。だが、その数行が、深夜のデバッグで君自身を救うことになる。優れたアーキテクチャとは、「ミスをしてもシステムが壊れない場所」を型で定義することに他ならないのだから。