コールバックの非同期例外を型でハックするな。TypeScriptで「例外伝播の型安全」を極める設計
コードレビューをしていて、もっとも背筋が凍る瞬間のひとつがこれだ。
// ❌ 良くある危険な実装:Promiseを返すのに型が `void` または `any` になっている
type BadCallback = (data: string) => void;
async function processWithCallback(callback: BadCallback) {
try {
// 呼び出し側が async 関数を渡した場合、ここで発生した非同期例外は握りつぶされる
callback(“test”);
} catch (e) {
console.error(“Caught:”, e); // ← ここには絶対に入らない
}
}
フロントエンドであれNode.jsであれ、非同期処理(`async/await` / `Promise`)は現代の開発において空気のような存在だ。しかし、「コールバック関数として渡される非同期処理のエラーが、呼び出し側でハンドリングされずにunhandled rejectionを引き起こす」というバグは、プロダクション環境で最も見落とされやすい時限爆弾の一つである。
なぜこの問題が起きるのか? そして、TypeScriptの型システムを極限まで活用して、「非同期コールバックの例外ハンドリングをコンパイル時に強制する」にはどうすればよいのか。
今回は、TypeScriptコアの型評価の挙動を見据えた、妥協なき堅牢な設計パターンを伝授しよう。
—
なぜ `(data: T) => void` では不十分なのか?
JavaScriptの関数は、`async` キーワードが付与されていなくても、`Promise` を返す関数を代入することができる。しかし、型定義が `() => void` や `() => any` である場合、コンパイラは次のような悲劇を防げない。
1. 呼び出し側(高階関数)は、コールバックが同期的に完了するものとして `try/catch` を組んでいる。
2. 渡されたコールバックが `async` 関数であるため、内部で例外が投げられた瞬間、それは `Promise` の拒否(Rejection)となり、同期的な `try/catch` をすり抜ける。
3. 結果として、アプリケーション全体がクラッシュするか、静かにサイレントエラーとして闇に葬られる。
これを防ぐためには、「コールバックが `Promise` を返す可能性を型レベルで強制し、呼び出し側が確実に `await` できる構造」を構築する必要がある。
—
決定版:非同期例外伝播を強制するプロダクションコード
以下のコードを見てほしい。これが、TypeScriptの型システムとランタイムの振る舞いを完全に調停させた、実務で即座に使える最高峰の設計パターンだ。
/
- —————————————————————-
- 1. 型定義層:非同期処理の例外伝播を強制するアーキテクチャ
- —————————————————————-
/
// コールバックは「同期的な値」または「Promise(非同期)」のどちらも返してよいが、
// 呼び出し側で必ずハンドリングできるよう、戻り値の型を `Promise
// ここであえて `void` を許容しないことで、呼び出し漏れを防ぐ。
type AsyncSafeCallback
…args: TArgs
) => Promise
// 実行結果の型安全性を担保するユーティリティ型
type ExecutionResult
| { success: true; data: T }
| { success: false; error: unknown };
/
- —————————————————————-
- 2. 実装層:ランタイムにおける堅牢なエラーバウンダリの提供
- —————————————————————-
/
/
- コールバックの同期・非同期の差異を吸収し、確実に例外をキャッチして型安全な結果を返すラッパー関数
/
export async function executeAsyncCallback
callback: AsyncSafeCallback
…args: TArgs
): Promise
try {
// 【極めて重要】
// コールバックが同期関数であっても async 関数であっても、
// `await` を通すことで同期エラーと Promise rejection の両方を同一の try/catch で捕捉できる。
const result = await callback(…args);
return { success: true, data: result };
} catch (error: unknown) {
// 意図しないthrowや非同期エラーを確実にここでトラップ
return { success: false, error };
}
}
/
- —————————————————————-
- 3. 応用層:コンポーネントやAPI連携での実践的使用例
- —————————————————————-
/
// モック:外部APIリクエストをシミュレートする非同期関数
async function updateDatabaseRecord(id: string, value: number): Promise
if (value < 0) {
throw new Error("Value must not be negative"); // 意図的な例外
}
return `Updated record ${id} with value ${value}`;
}
// メインの処理パイプライン
async function runPipeline() {
const recordId = "usr_9981";
const incomingValue = -5; // エラーケースの誘発
// executeAsyncCallback を通すことで、コールバック内の非同期例外が
// 実行時エラーとしてクラッシュせず、必ず Union 型のオブジェクトとして返される。
const result = await executeAsyncCallback(
(id: string, val: number) => updateDatabaseRecord(id, val),
recordId,
incomingValue
);
if (!result.success) {
// TypeScriptのコントロールフロー分析により、このブロック内では result.error が unknown として安全に扱える
console.error(“Pipeline failed gracefully:”, (result.error as Error).message);
// ユーザーへのフォールバック処理やエラーログ送信などをここに記述
return;
}
// ここに到達した時点で result.data の型は string に絞り込まれている
console.log(“Pipeline succeeded:”, result.data);
}
—
この設計が優れている「3つの理由」
1. 同期関数と非同期関数の完全な抽象化(`Promise | TReturn`)
開発現場において、コールバックには「同期的に処理が終わる軽い関数」もあれば「ネットワークを叩く重い非同期関数」も混在する。
この型定義では、どちらを渡してもTypeScriptコンパイラが怒らず、かつランタイム側では `await` によって両者をシームレスに統一して扱える。
2. Union型による「例外の隠蔽禁止」
`try/catch` を使うコードは、しばしばエラーハンドリングの記述漏れを生む。しかし、上のコードで示した `ExecutionResult
3. パフォーマンスとメモリ効率の最適化
無駄なラッパーオブジェクトの生成を最小限に抑えつつ、V8エンジンの最適化パス(Hidden Classの安定化)を阻害しないプレーンなオブジェクト構造を維持している。これにより、高頻度で呼び出されるイベントハンドラーやストリーム処理の中でもオーバーヘッドを極小化できる。
—
テクニカルリードからのメッセージ
「動けばいいや」という雑な型定義 (`any` や `Function`、雑な `void`) は、その瞬間はコーディングの速度を上げるかもしれない。しかし、それは技術的負債という名の複利を生み出し、やがてプロダクション環境での大規模な障害へと繋がる。
TypeScriptの本質は、「実行時エラーの可能性を、コンパイル時の型空間へ美しく射影すること」にある。
今日からあなたのプロジェクトでも `void` を返す非同期コールバックの放置をやめ、型で例外をコントロールする堅牢なアーキテクチャを取り入れてほしい。コードの信頼性は、間違いなく一段上のステージへ引き上げられるはずだ。