【実務・中級編】戻り値の型として「Promise」を返す関数における非同期エラーハンドリングの型定義 – TypeScript コア・型システムの基礎解析バイブル

戻り値の型として `Promise` を返す関数における非同期エラーハンドリングの型定義:例外の迷宮から抜け出すための設計論

コードレビューをしていて、次のようなコードに遭遇したことはないだろうか。

// 良くあるアンチパターン
async function fetchUserData(userId: string): Promise {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(‘Failed to fetch user’); // ここで例外をスローしている
}
return response.json();
}

一見、何の問題もないように見えるかもしれない。しかし、この関数を呼び出す側(Consumer)の視点に立った瞬間、TypeScriptの型システムにおける「隠された罠」が牙をむく。

シグネチャが `Promise` であるにもかかわらず、呼び出し元では `try/catch` が強制されない。なぜなら、TypeScriptの `throws` 句の欠如(あるいは未サポート)により、非同期関数がどのような例外を投げるのかを型レベルで追跡できないからだ。結果として、ランタイムのエラーハンドリング漏れによるバグが本番環境で爆発する。

今回は、非同期関数の戻り値とエラーを型レベルで完全に制御し、呼び出し元に安全なエラーハンドリングを強制するプロダクションクオリティの設計パターンを解説する。

—

1. なぜ `throw` は型システムを破壊するのか?

TypeScriptのコンパイラは、関数の戻り値の型(例: `Promise`)を保証することはできるが、その関数が「どのようなエラーを投げる可能性あるか」を静的に追跡する機能を持っていない(Javaのチェック例外のような仕組みはあえて採用されていない)。

そのため、`async/await` や `Promise` を使うコードでは、以下の2つの大きな問題が生じる。

1. 例外の握りつぶし: 呼び出し元が `try/catch` を書き忘れても、コンパイルエラーにならない。
2. `any` の蔓延: キャッチしたエラー(`catch (error)`)の型はデフォルトで `unknown`(あるいは古い設定では `any`)になり、`error.message` にアクセスするために煩雑な型ガードを毎回書かされる。

これらを解決するためのアプローチが、「例外を値(Value)として扱う(Either / Result型パターン)」だ。

—

2. 設計の核心:Result型による非同期エラーの型安全化

関数が失敗する可能性を、例外(Exception)ではなく「戻り値のバリエーション」として表現する。これにより、コンパイラは呼び出し元に対して「成功と失敗の両方のケースをハンドリングしなさい」と強制できるようになる。

以下のプロダクションコードを見てほしい。これが、私たちが現場で採用すべきモダンな非同期エラーハンドリングの基準だ。

プロダクションコード例

/

  • 処理の成功を表す型

/
export type Success = {
readonly success: true;
readonly data: T;
};

/

  • 処理の失敗(エラー)を表す型
  • E はエラーのドメイン型を制限するために設ける

/
export type Failure = {
readonly success: false;
readonly error: E;
};

/

  • 成功と失敗を内包するResult型

/
export type Result = Success | Failure;

/

  • 非同期処理を安全にラップし、例外をResult型に変換するユーティリティ関数
  • @param promise 実行するPromise
  • @returns Result 型のPromise

/
export async function safeAsync(
promise: Promise
): Promise> {
try {
const data = await promise;
return { success: true, data };
} catch (error) {
// 予期せぬ非同期エラー(stringやunknownなど)をE型に安全にキャスト・調停する
return { success: false, error: error as E };
}
}

—

3. 型評価とコンパイル時の振る舞い

上記の `Result` 型と `safeAsync` を用いた場合、TypeScriptの型システムはどのように動作するだろうか。

// ドメイン固有のエラー定義
type UserNotFoundError = { readonly _tag: ‘UserNotFoundError’; readonly userId: string };
type NetworkError = { readonly _tag: ‘NetworkError’; readonly message: string };

type FetchUserError = UserNotFoundError | NetworkError;

// ユーザー取得関数(内部で例外を投げる可能性があるとする)
async function fetchUser(userId: string): Promise {
if (userId === ”) {
throw { _tag: ‘UserNotFoundError’, userId } as UserNotFoundError;
}
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) {
throw { _tag: ‘NetworkError’, message: ‘Network failed’ } as NetworkError;
}
return res.json();
}

// ==========================================
// 呼び出し元のコード(Consumer)
// ==========================================
async function handleLogin(userId: string) {
// safeAsyncを使うことで、戻り値は Promise> に確定する
const result = await safeAsync(fetchUser(userId));

// ここでTypeScriptの「区別された共用体(Discriminated Unions)」の型ガードが発動する
if (!result.success) {
// コンパイラはここで result が Failure であることを完全に理解している
switch (result.error._tag) {
case ‘UserNotFoundError’:
console.warn(`User not found: ${result.error.userId}`);
return;
case ‘NetworkError’:
console.error(`Network issue: ${result.error.message}`);
return;
}
}

// ここに到達した時点で、result.data は確実に User 型として安全に扱える
console.log(`Welcome back, ${result.data.name}`);
}

この設計がもたらす圧倒的なメリット

1. `try/catch` の呪縛からの解放: 非同期関数の中で何がスローされようとも、呼び出し元は `try` ブロックを書く必要がない。すべてが型安全な `if (!result.success)` の分岐に収束する。
2. 網羅性の担保(Exhaustiveness Checking): `switch` 文や `if` 分岐において、エラーパターンのハンドリング漏れが発生した場合、TypeScriptコンパイラが警告(あるいは `never` 型を使った網羅性チェック)を出すことが可能になる。

—

4. パフォーマンス上の注意点とV8エンジン最適化の視点

「すべての非同期処理をオブジェクト(`Success` / `Failure`)でラップする」ことに対して、シニアエンジニアならこう疑問に思うはずだ。
「毎回の非同期呼び出しでオブジェクトの生成コスト(GCのプレッシャー)が発生しないか?」

結論から言えば、一般的なWebフロントエンドや通常のNode.js APIサーバーの負荷において、この程度のオブジェクトアロケーションによるパフォーマンス低下は完全に無視できるレベルである。V8エンジンのジェネレーション別GC(Garbage Collector)は、短命なオブジェクト(Young Generation)の回収を極めて高速に行う。

ただし、極限のスループットが要求される基盤ミドルウェアや、1秒間に数十万回実行されるホットパス(Hot Path)においては話が別だ。
その場合は、無駄なオブジェクト生成を避けるために、以下のような「キャッシュ済み成功オブジェクト」や「型の最適化」を検討する余地はある。

// 高頻度実行パス用のイミュータブルな定数(必要に応じた最適化の例)
const EMPTY_SUCCESS = Object.freeze({ success: true, data: undefined } as const);

だが、ビジネスロジックの複雑性とコードの保守性を天秤にかけたとき、「実行時エラーのバグゼロ化」というメリットは、微小なCPUコストを何倍も上回るリターンをもたらす。

—

5. テクニカルリードからの総括

非同期エラーハンドリングにおける型定義の甘さは、フロントエンドのサイレントバグ(画面のフリーズ、無限ローディング、未定義プロパティアクセスによるクラッシュ)の最大の温床である。

「たまたま例外が発生しなかった」という曖昧な状態を排除し、「成功と失敗のどちらのルートも型として明示的に実装しなければコンパイルが通らない」という堅牢な構造をチームに導入すること。それこそが、TypeScriptを真に使いこなし、プロダクトの品質を次元進化させるチーフアーキテクトの仕事である。

今日のコードレビューから、あなたのプロジェクトの `async/await` に `Result` 型の思想を取り入れてみてほしい。コードの美しさと静的解析の安心感に、開発フィールが劇的に変わるはずだ。

タイトルとURLをコピーしました