【JS応用】”再スロー” を async/await で書き直す

async/await環境における例外再スローの最適解と設計パターン

現代のフロントエンド開発において、非同期処理の標準であるasync/awaitは、コードの可読性を飛躍的に向上させました。しかし、Promiseチェーン時代に頻繁に行われていた「エラーを補足して加工し、再度投げる(再スロー)」というパターンを、async/awaitでどのように実装すべきか、あるいは実装すべきでないのかという点については、多くのエンジニアが迷いを感じています。本記事では、例外の再スローを再考し、堅牢な非同期エラーハンドリングを実現するためのベストプラクティスを詳細に解説します。

再スローの概念とその本質

「再スロー(Rethrowing)」とは、try/catchブロック内でエラーを一度受け取り、何らかの処理(ログ出力や状態更新など)を行った後、再度そのエラーをthrowキーワードで呼び出し元へ伝播させる手法を指します。

従来、Promiseチェーンでは以下のように記述されていました。

fetchData()
  .catch(err => {
    logger.error('API Error:', err);
    throw err; // 再スロー
  });

async/awaitにおいてこれをそのまま置き換えると、単純なtry/catchブロックになります。しかし、単に再スローするだけであれば、そもそもcatchを書く必要はあるのでしょうか。この疑問こそが、エラーハンドリング戦略の核心です。再スローの主な目的は「エラーの文脈(Context)を付与すること」と「サイドエフェクト(ログ出力や解析)の実行」の二点に集約されます。

async/awaitにおける再スローの書き換え手法

async/await環境では、エラーを再スローする際に「Errorオブジェクトの拡張」を行うのが一般的かつ推奨される手法です。単にエラーを投げるのではなく、スタックトレースを維持しつつ、より詳細な情報を付与することで、デバッグ効率が劇的に向上します。

async function fetchUserProfile(userId) {
  try {
    const response = await api.get(`/users/${userId}`);
    return response.data;
  } catch (error) {
    // 発生したエラーをラップして文脈を付与する
    const enhancedError = new Error(`ユーザープロファイルの取得に失敗しました: ${userId}`);
    enhancedError.cause = error; // 元のエラーを保持する(ES2022以降)
    throw enhancedError;
  }
}

ここで重要なのは、`error.cause`プロパティの使用です。これにより、元々のエラー情報を失うことなく、上位レイヤーへより具体的なエラーメッセージを伝えることができます。この手法は、UI層でエラーを表示する際に「何が起きたか」と「なぜ起きたか」を分離して扱うことを可能にします。

アンチパターンとしての「無意味な再スロー」

多くのエンジニアが陥りがちな罠が、「処理を行わない再スロー」です。

async function process() {
  try {
    await doSomething();
  } catch (err) {
    throw err; // これは何の意味も持たない
  }
}

このコードは、呼び出し元から見れば何もしていないのと同義であり、単にスタックトレースを汚染するだけの結果となります。async/awaitにおいて、エラーをキャッチした後に単に再スローするだけなら、そもそもtry/catchを記述せず、呼び出し元にエラーを委譲(Delegate)するべきです。エラーは「処理できる場所」または「報告できる場所」までバブリングさせるのが、設計の基本原則です。

階層化されたエラーハンドリング戦略

フロントエンドのアプリケーション構成において、エラーは「UI層」と「データ取得層(サービス層)」で役割を分担させるべきです。

サービス層では、APIからのレスポンスエラーをドメイン固有のエラーに変換して再スローします。一方、UI層(コンポーネント)では、そのエラーを受け取り、ユーザーに通知する役割を担います。

// サービス層
async function updateSettings(data) {
  try {
    return await api.patch('/settings', data);
  } catch (err) {
    if (err.response?.status === 403) {
      throw new Error('権限がありません。管理者に連絡してください。');
    }
    throw new Error('予期せぬネットワークエラーが発生しました。');
  }
}

// UIコンポーネント層
async function handleSave() {
  try {
    await updateSettings(formData);
    showToast('保存しました');
  } catch (err) {
    showErrorDialog(err.message); // ユーザーフレンドリーなメッセージを表示
  }
}

この設計により、サービス層はビジネスロジックに集中し、UI層はユーザー体験に集中することができます。再スローは、この「層の橋渡し」として機能します。

実務における注意点とパフォーマンスへの影響

再スローを多用する場合、いくつかの注意点が存在します。

1. スタックトレースの汚染: JavaScriptエンジンはエラーが生成されるたびにスタックトレースを作成します。過度な再スローは、デバッグ時に本来のエラー発生源を特定しづらくする可能性があります。
2. 非同期の文脈の維持: `Promise.all`等を使用している場合、再スローされたエラーがどこで発生したのかを特定するために、各Promiseに名前やIDを付与する工夫が必要です。
3. 型安全性の確保: TypeScriptを使用している場合、catch節のerrorは`unknown`型です。再スローする際は、エラーの型をガード(instanceofチェックなど)し、意図しないオブジェクトがスローされないようにする必要があります。

実務においては、エラーをクラス化することをお勧めします。

class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
    this.name = 'ApiError';
  }
}

// 活用例
if (error instanceof ApiError) {
  // 特定のエラーに対するリカバリーロジック
}

まとめ

async/await環境における再スローは、単なるコードの書き換えではありません。それは「エラーのライフサイクルを設計する」というアーキテクチャ上の意思決定です。

・意味のない再スローは削除する(エラーを伝播させる)。
・エラーをラップし、文脈を付与する(Error.causeの活用)。
・層ごとにエラーの責務を分ける(ドメインエラーへの変換)。
・型安全性を担保し、エラーの状態を可視化する。

これらの原則を守ることで、フロントエンドのアプリケーションはより堅牢になり、予期せぬクラッシュや、原因不明のバグから解放されます。エラーはアプリケーションの一部であり、それをどう扱うかがエンジニアの質を決定します。ぜひ、日々の実装において、この「再スローの設計」を意識してみてください。

コメント

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