T-CREATOR

JavaScript 非同期エラー完全攻略:Unhandled Rejection を根絶する設計パターン

JavaScript 非同期エラー完全攻略:Unhandled Rejection を根絶する設計パターン

JavaScript の非同期処理でアプリケーションが突然クラッシュしたり、エラーが握りつぶされたりした経験はありませんか?その原因の多くは Unhandled Promise Rejection です。本記事では、Unhandled Rejection の仕組みを理解し、設計レベルで根絶するための実践的なパターンを解説します。

初心者の方でも理解できるよう、基礎から応用まで段階的に説明していきますので、安心してお読みいただけます。この記事を読み終える頃には、堅牢な非同期エラーハンドリングを実装できるようになっているでしょう。

背景

JavaScript における非同期処理の進化

JavaScript は Single Thread で動作する言語ですが、非同期処理により効率的な I/O 処理を実現しています。非同期処理の仕組みは時代とともに進化してきました。

以下の図は、JavaScript の非同期処理がどのように進化してきたかを示しています。

mermaidflowchart LR
  callback["Callback 関数<br/>(ES5)"] -->|コールバック地獄| promise["Promise<br/>(ES6)"]
  promise -->|可読性向上| async["async/await<br/>(ES2017)"]

  style callback fill:#ffcccc
  style promise fill:#ffffcc
  style async fill:#ccffcc

この図から、Callback から Promise、そして async/await へと移行することで、コードの可読性と保守性が大きく向上したことがわかります。

Promise が解決した課題

Promise の登場により、以下の課題が解決されました。

#課題Promise による解決
1コールバック地獄(Callback Hell)チェーン可能な .then() による平坦化
2エラーハンドリングの複雑さ.catch() による統一的なエラー処理
3非同期処理の状態管理Pending/Fulfilled/Rejected の 3 状態
4複数の非同期処理の制御Promise.all()Promise.race()

しかし、Promise の普及に伴い、新たな問題が浮上しました。それが Unhandled Promise Rejection です。

Unhandled Promise Rejection とは

Unhandled Promise Rejection は、Promise が reject されたにもかかわらず、.catch() や try-catch でエラーをキャッチしていない状態を指します。

以下は、Unhandled Rejection が発生する基本的なフローです。

mermaidsequenceDiagram
  participant Code as コード
  participant Promise as Promise
  participant EventLoop as Event Loop
  participant Handler as Error Handler

  Code->>Promise: 非同期処理実行
  Promise->>Promise: エラー発生(reject)
  Promise->>EventLoop: Rejection イベント

  alt .catch() がある場合
    EventLoop->>Handler: エラーハンドリング実行
    Handler-->>Code: エラー処理完了
  else .catch() がない場合
    EventLoop->>Handler: unhandledRejection イベント発火
    Handler-->>Code: プロセスクラッシュ(Node.js)<br/>コンソールエラー(Browser)
  end

この図が示すように、エラーハンドリングが適切に行われない場合、アプリケーションの安定性に深刻な影響を及ぼします。

課題

Unhandled Rejection が引き起こす問題

Unhandled Promise Rejection は、以下のような深刻な問題を引き起こします。

サーバーサイドでの影響(Node.js)

Node.js 環境では、Unhandled Rejection は特に危険です。Node.js v15 以降、デフォルトで プロセスが終了する 動作に変更されました。

javascript// Unhandled Rejection の例
async function fetchUserData(userId) {
  // エラーハンドリングなし
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
}

// この呼び出しでエラーが発生するとプロセスがクラッシュ
fetchUserData('invalid-id');

上記のコードでは、fetch が失敗した場合に .catch() や try-catch がないため、Unhandled Rejection が発生します。

実際のエラーメッセージは以下のようになります。

vbnetUnhandledPromiseRejectionWarning: Error: Request failed with status code 404
    at fetchUserData (/app/index.js:3:15)
(Use `node --trace-warnings ...` to show where the warning was created)
UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch().

クライアントサイドでの影響(Browser)

ブラウザ環境では、プロセスクラッシュは起きませんが、以下の問題が発生します。

#問題影響
1エラーが握りつぶされるユーザーに適切なエラーメッセージが表示されない
2状態の不整合UI の状態が中途半端になる
3デバッグの困難さエラーの原因特定に時間がかかる
4ユーザー体験の悪化画面がフリーズしたように見える

よくある Unhandled Rejection のパターン

以下は、実際の開発現場で頻繁に見られる Unhandled Rejection のパターンです。

パターン 1:async 関数の呼び出し忘れ

async 関数を呼び出す際、await を付け忘れたり、エラーハンドリングを省略したりするケースです。

javascript// NG: エラーハンドリングなし
async function updateProfile(userId, data) {
  const user = await getUserById(userId);
  user.profile = data;
  await saveUser(user); // このエラーが握りつぶされる
}

// この呼び出しでエラーが発生しても気づけない
updateProfile(123, { name: 'John' });

パターン 2:イベントハンドラー内での非同期処理

イベントハンドラー内で非同期処理を行う際、エラーハンドリングを忘れがちです。

javascript// NG: イベントハンドラー内でのエラーハンドリング欠如
button.addEventListener('click', async () => {
  const data = await fetchData(); // エラーハンドリングなし
  updateUI(data);
});

上記のコードでは、fetchData() がエラーを投げた場合、それが catch されません。

パターン 3:Promise.all での部分的なエラー

複数の Promise を並列実行する際、一部のエラーだけがキャッチされないケースです。

javascript// NG: 一部の Promise だけエラーハンドリング
const results = await Promise.all([
  fetchUserData(1).catch((err) => null),
  fetchUserData(2), // この Promise のエラーが Unhandled
  fetchUserData(3).catch((err) => null),
]);

以下の図は、Unhandled Rejection が発生する典型的なコードパスを示しています。

mermaidflowchart TD
  start["非同期関数呼び出し"] --> hasAwait{"await を<br/>使用?"}
  hasAwait -->|Yes| hasTryCatch{"try-catch<br/>ブロック?"}
  hasAwait -->|No| unhandled1["Unhandled Rejection<br/>発生"]

  hasTryCatch -->|Yes| safe["安全"]
  hasTryCatch -->|No| unhandled2["Unhandled Rejection<br/>発生"]

  start --> chainPromise{"Promise<br/>チェーン?"}
  chainPromise -->|Yes| hasCatch{".catch()<br/>メソッド?"}
  chainPromise -->|No| hasEventHandler{"イベント<br/>ハンドラー?"}

  hasCatch -->|Yes| safe
  hasCatch -->|No| unhandled3["Unhandled Rejection<br/>発生"]

  hasEventHandler -->|Yes| hasErrorHandler{"エラー<br/>ハンドラー?"}
  hasEventHandler -->|No| end_task["処理終了"]

  hasErrorHandler -->|Yes| safe
  hasErrorHandler -->|No| unhandled4["Unhandled Rejection<br/>発生"]

  style unhandled1 fill:#ffcccc
  style unhandled2 fill:#ffcccc
  style unhandled3 fill:#ffcccc
  style unhandled4 fill:#ffcccc
  style safe fill:#ccffcc

この図から、エラーハンドリングを行うべきポイントが複数あることがわかります。すべてのポイントで適切な対策を行う必要があります。

解決策

基本的なエラーハンドリングパターン

Unhandled Rejection を防ぐための基本パターンを、段階的に解説します。

パターン A:try-catch による同期的なエラーハンドリング

async/await を使用する場合、try-catch ブロックで囲むのが最も直感的です。

javascript// OK: try-catch でエラーをキャッチ
async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);

    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error('ユーザーデータの取得に失敗:', error);
    throw error; // 上位でハンドリングする場合は再スロー
  }
}

このコードでは、fetch の失敗と HTTP エラーの両方を適切にキャッチしています。エラーメッセージも具体的で、デバッグしやすくなっています。

try-catch を使用する際の注意点を表にまとめました。

#ポイント詳細
1エラーの再スロー上位でハンドリングする必要がある場合は throw error
2エラーメッセージの具体化HTTP Error: 404 のように詳細情報を含める
3ロギングエラーを必ずログに記録する
4リソースのクリーンアップfinally ブロックでリソースを解放

パターン B:.catch() による Promise チェーン

Promise チェーンを使用する場合は、.catch() メソッドでエラーをキャッチします。

javascript// OK: .catch() でエラーハンドリング
function loadUserProfile(userId) {
  return fetch(`/api/users/${userId}`)
    .then((response) => {
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      return response.json();
    })
    .catch((error) => {
      console.error('プロフィール読み込みエラー:', error);
      // デフォルト値を返す
      return { id: userId, name: 'Unknown' };
    });
}

.catch() を使用すると、エラー時のフォールバック値を簡潔に返せます。上記では、エラー発生時にデフォルトのユーザーオブジェクトを返しています。

パターン C:Promise.allSettled() による並列処理

複数の Promise を並列実行する際、Promise.allSettled() を使用すると、一部が失敗してもすべての結果を取得できます。

javascript// OK: Promise.allSettled() で全結果を取得
async function fetchMultipleUsers(userIds) {
  const promises = userIds.map((id) => fetchUserData(id));

  const results = await Promise.allSettled(promises);

  return results;
}

Promise.allSettled() は各 Promise の結果を以下の形式で返します。

javascript// 成功した Promise
{ status: 'fulfilled', value: { id: 1, name: 'John' } }

// 失敗した Promise
{ status: 'rejected', reason: Error('Not found') }

結果の処理は以下のように行います。

javascriptconst results = await fetchMultipleUsers([1, 2, 3]);

// 成功したユーザーだけを抽出
const successUsers = results
  .filter((result) => result.status === 'fulfilled')
  .map((result) => result.value);

// 失敗したユーザーIDをログ出力
results
  .filter((result) => result.status === 'rejected')
  .forEach((result, index) => {
    console.error(
      `User ${index + 1} 取得失敗:`,
      result.reason
    );
  });

設計パターンによる根絶策

基本的なエラーハンドリングに加えて、設計レベルで Unhandled Rejection を防ぐパターンを紹介します。

設計パターン 1:Error Boundary パターン

アプリケーション全体でエラーをキャッチする境界を設けるパターンです。

以下の図は、Error Boundary パターンの構造を示しています。

mermaidflowchart TD
  app["Application"] --> boundary["Error Boundary<br/>(最上位)"]
  boundary --> module1["Module A<br/>Error Handler"]
  boundary --> module2["Module B<br/>Error Handler"]
  boundary --> module3["Module C<br/>Error Handler"]

  module1 --> func1["async 関数群"]
  module2 --> func2["async 関数群"]
  module3 --> func3["async 関数群"]

  func1 -.->|エラー| module1
  func2 -.->|エラー| module2
  func3 -.->|エラー| module3

  module1 -.->|未処理エラー| boundary
  module2 -.->|未処理エラー| boundary
  module3 -.->|未処理エラー| boundary

  style boundary fill:#ffcccc
  style module1 fill:#ffffcc
  style module2 fill:#ffffcc
  style module3 fill:#ffffcc

図が示すように、各モジュールでキャッチされなかったエラーは、最上位の Error Boundary でキャッチします。

実装例を見てみましょう。

javascript// Error Boundary の実装
class AsyncErrorBoundary {
  constructor() {
    this.errorHandlers = [];
    this.setupGlobalHandlers();
  }

  setupGlobalHandlers() {
    // Node.js 環境
    if (typeof process !== 'undefined') {
      process.on('unhandledRejection', (reason, promise) => {
        this.handleError(reason, { promise });
      });
    }

Global ハンドラーの設定により、すべての Unhandled Rejection をキャッチできます。

javascript    // Browser 環境
    if (typeof window !== 'undefined') {
      window.addEventListener('unhandledrejection', (event) => {
        this.handleError(event.reason, { event });
        event.preventDefault(); // デフォルト動作を抑制
      });
    }
  }

ブラウザでは unhandledrejection イベントをリスニングします。preventDefault() を呼ぶことで、コンソールエラーの出力を制御できます。

javascript  handleError(error, context = {}) {
    // エラー情報を整形
    const errorInfo = {
      message: error.message || String(error),
      stack: error.stack,
      timestamp: new Date().toISOString(),
      context
    };

    // 登録されたハンドラーを実行
    this.errorHandlers.forEach(handler => {
      try {
        handler(errorInfo);
      } catch (handlerError) {
        console.error('Error handler 自体がエラー:', handlerError);
      }
    });
  }

エラーハンドラー自体がエラーを投げる可能性も考慮して、try-catch で保護しています。

javascript  registerHandler(handler) {
    this.errorHandlers.push(handler);
  }

  removeHandler(handler) {
    const index = this.errorHandlers.indexOf(handler);
    if (index > -1) {
      this.errorHandlers.splice(index, 1);
    }
  }
}

Error Boundary の使用例は以下の通りです。

javascript// Error Boundary の使用
const errorBoundary = new AsyncErrorBoundary();

// カスタムエラーハンドラーの登録
errorBoundary.registerHandler((errorInfo) => {
  console.error('Unhandled エラーを検知:', errorInfo);

  // ログサービスに送信
  logToServer(errorInfo);

  // ユーザーに通知
  showErrorNotification('エラーが発生しました');
});

設計パターン 2:Safe Async Wrapper パターン

すべての非同期関数を自動的にエラーハンドリングでラップするパターンです。

javascript// Safe Async Wrapper の実装
function safeAsync(asyncFn, options = {}) {
  const {
    onError = (error) => console.error(error),
    defaultValue = null,
    retries = 0,
    retryDelay = 1000
  } = options;

  return async function(...args) {
    let lastError;

    // リトライロジック
    for (let attempt = 0; attempt <= retries; attempt++) {

Wrapper 関数では、リトライ機能も実装しています。リトライ回数と遅延時間はオプションで指定できます。

javascript      try {
        const result = await asyncFn.apply(this, args);
        return result;
      } catch (error) {
        lastError = error;

        // 最後のリトライでない場合は待機
        if (attempt < retries) {
          await new Promise(resolve =>
            setTimeout(resolve, retryDelay)
          );
          continue;
        }

リトライの間に遅延を設けることで、一時的なネットワークエラーなどから回復できます。

javascript        // すべてのリトライが失敗
        onError(error);
        return defaultValue;
      }
    }
  };
}

使用例を見てみましょう。

javascript// Safe Async Wrapper の使用例
const safeFetchUser = safeAsync(
  async (userId) => {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok)
      throw new Error(`HTTP ${response.status}`);
    return response.json();
  },
  {
    onError: (err) =>
      console.error('ユーザー取得エラー:', err),
    defaultValue: { id: null, name: 'Unknown' },
    retries: 2,
    retryDelay: 1000,
  }
);

// エラーハンドリングを意識せずに使用可能
const user = await safeFetchUser(123);
console.log(user.name); // エラー時も安全にアクセスできる

この Wrapper を使えば、個別の try-catch を書かずに済みます。

設計パターン 3:Result Type パターン

エラーを値として扱い、型安全なエラーハンドリングを実現するパターンです。このパターンは Rust や Go などの言語で採用されています。

typescript// Result 型の定義
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

// Result を返す関数
async function fetchUserResult(userId: number): Promise<Result<User>> {
  try {
    const response = await fetch(`/api/users/${userId}`);

    if (!response.ok) {
      return {
        ok: false,
        error: new Error(`HTTP ${response.status}`)
      };
    }

エラーが発生した場合、例外をスローするのではなく、ok: false のオブジェクトを返します。

typescript    const user = await response.json();
    return { ok: true, value: user };

  } catch (error) {
    return {
      ok: false,
      error: error instanceof Error ? error : new Error(String(error))
    };
  }
}

Result Type の使用例は以下の通りです。

typescript// Result 型の使用
const result = await fetchUserResult(123);

if (result.ok) {
  // TypeScript が value の型を正しく推論
  console.log(result.value.name);
} else {
  // TypeScript が error の型を正しく推論
  console.error('エラー:', result.error.message);
}

このパターンの利点は、エラーハンドリングを強制できる点です。result.ok をチェックしないと、TypeScript がコンパイルエラーを出します。

具体例

実践例 1:API クライアントの実装

実際のプロジェクトで使える、堅牢な API クライアントを実装してみましょう。

API クライアントの基本構造

まず、API クライアントの全体構造を図で確認します。

mermaidflowchart LR
  app["Application"] -->|リクエスト| client["API Client"]
  client -->|前処理| interceptor["Request Interceptor"]
  interceptor -->|HTTP| api["Backend API"]
  api -->|レスポンス| handler["Response Handler"]
  handler -->|エラー判定| errorHandler["Error Handler"]
  errorHandler -->|Result| app

  errorHandler -.->|リトライ| interceptor
  errorHandler -.->|ログ| logger["Logger"]

  style errorHandler fill:#ffcccc
  style client fill:#ccffcc

この図から、リクエストからレスポンスまでの流れと、エラーハンドリングの位置づけが理解できます。

Step 1:基本クラスの定義

API クライアントの基本クラスを定義します。

typescript// API クライアントの基本クラス
class ApiClient {
  private baseUrl: string;
  private defaultHeaders: Record<string, string>;
  private requestInterceptors: Array<RequestInterceptor> = [];
  private responseInterceptors: Array<ResponseInterceptor> = [];

  constructor(config: ApiClientConfig) {
    this.baseUrl = config.baseUrl;
    this.defaultHeaders = config.headers || {};
  }

Step 2:リクエストメソッドの実装

HTTP リクエストを送信する基本メソッドを実装します。

typescript  async request<T>(
    endpoint: string,
    options: RequestOptions = {}
  ): Promise<Result<T>> {
    const url = `${this.baseUrl}${endpoint}`;
    const config = this.buildRequestConfig(options);

    try {
      // Request Interceptor の実行
      const modifiedConfig = await this.runRequestInterceptors(config);

Request Interceptor では、認証トークンの追加やリクエストのログ出力などを行います。

typescript      // Fetch の実行
      const response = await fetch(url, modifiedConfig);

      // Response Interceptor の実行
      const processedResponse = await this.runResponseInterceptors(response);

      // レスポンスの検証
      return await this.handleResponse<T>(processedResponse);

    } catch (error) {
      return this.handleError(error);
    }
  }

Step 3:レスポンスハンドリング

レスポンスを適切に処理するメソッドを実装します。

typescript  private async handleResponse<T>(response: Response): Promise<Result<T>> {
    // HTTP ステータスコードのチェック
    if (!response.ok) {
      const errorBody = await response.text();

      return {
        ok: false,
        error: {
          code: `HTTP_${response.status}`,
          message: `HTTP Error: ${response.status} ${response.statusText}`,
          details: errorBody,
          statusCode: response.status
        }
      };
    }

HTTP エラーの場合、詳細なエラー情報を含む Result オブジェクトを返します。

typescript    // JSON のパース
    try {
      const data = await response.json();
      return { ok: true, value: data as T };
    } catch (parseError) {
      return {
        ok: false,
        error: {
          code: 'PARSE_ERROR',
          message: 'レスポンスのパースに失敗しました',
          details: parseError
        }
      };
    }
  }

Step 4:エラーハンドリング

包括的なエラーハンドリングを実装します。

typescript  private handleError(error: unknown): Result<never> {
    // ネットワークエラー
    if (error instanceof TypeError && error.message.includes('fetch')) {
      return {
        ok: false,
        error: {
          code: 'NETWORK_ERROR',
          message: 'ネットワークエラーが発生しました',
          details: error,
          retryable: true
        }
      };
    }

ネットワークエラーの場合、retryable: true フラグを設定し、リトライ可能であることを示します。

typescript// タイムアウトエラー
if (error instanceof Error && error.name === 'AbortError') {
  return {
    ok: false,
    error: {
      code: 'TIMEOUT_ERROR',
      message: 'リクエストがタイムアウトしました',
      details: error,
      retryable: true,
    },
  };
}
typescript    // その他のエラー
    return {
      ok: false,
      error: {
        code: 'UNKNOWN_ERROR',
        message: '予期しないエラーが発生しました',
        details: error
      }
    };
  }

Step 5:リトライロジックの実装

自動リトライ機能を追加します。

typescript  async requestWithRetry<T>(
    endpoint: string,
    options: RequestOptions = {},
    retryConfig: RetryConfig = { maxRetries: 3, delay: 1000 }
  ): Promise<Result<T>> {
    let lastResult: Result<T>;

    for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
      lastResult = await this.request<T>(endpoint, options);
typescript      // 成功した場合はそのまま返す
      if (lastResult.ok) {
        return lastResult;
      }

      // リトライ不可能なエラーの場合は即座に返す
      if (!lastResult.error.retryable) {
        return lastResult;
      }

      // 最後のリトライでない場合は待機
      if (attempt < retryConfig.maxRetries) {
        await this.delay(retryConfig.delay * Math.pow(2, attempt));
      }
    }

    return lastResult!;
  }

Exponential Backoff を実装しており、リトライごとに待機時間が増加します。

API クライアントの使用例

実装した API クライアントを使用してみましょう。

typescript// API クライアントの初期化
const apiClient = new ApiClient({
  baseUrl: 'https://api.example.com',
  headers: {
    'Content-Type': 'application/json',
  },
});

// ユーザー情報の取得
const result = await apiClient.requestWithRetry<User>(
  '/users/123',
  { method: 'GET' }
);

if (result.ok) {
  console.log('ユーザー名:', result.value.name);
} else {
  // エラーコードに応じた処理
  switch (result.error.code) {
    case 'HTTP_404':
      console.error('ユーザーが見つかりません');
      break;
    case 'NETWORK_ERROR':
      console.error('ネットワークに接続できません');
      break;
    default:
      console.error('エラー:', result.error.message);
  }
}

実践例 2:React での実装

React アプリケーションでの Unhandled Rejection 対策を実装します。

カスタムフックの作成

非同期処理を安全に扱うカスタムフックを作成しましょう。

typescript// useAsyncSafe フックの実装
function useAsyncSafe<T>(
  asyncFunction: () => Promise<T>,
  dependencies: React.DependencyList = []
) {
  const [state, setState] = React.useState<{
    data: T | null;
    error: Error | null;
    loading: boolean;
  }>({
    data: null,
    error: null,
    loading: true
  });

State には、data、error、loading の 3 つのプロパティを持たせます。

typescript  React.useEffect(() => {
    let cancelled = false;

    const execute = async () => {
      try {
        setState(prev => ({ ...prev, loading: true, error: null }));

        const result = await asyncFunction();

        // コンポーネントがアンマウントされていない場合のみ更新
        if (!cancelled) {
          setState({ data: result, error: null, loading: false });
        }

cancelled フラグにより、アンマウント後の State 更新を防ぎます。これは React の Warning を防ぐ重要なパターンです。

typescript      } catch (error) {
        if (!cancelled) {
          setState({
            data: null,
            error: error instanceof Error ? error : new Error(String(error)),
            loading: false
          });
        }
      }
    };

    execute();

    // クリーンアップ関数
    return () => {
      cancelled = true;
    };
  }, dependencies);

  return state;
}

エラーバウンダリーコンポーネント

React の Error Boundary を拡張して、非同期エラーもキャッチできるようにします。

typescript// AsyncErrorBoundary コンポーネント
class AsyncErrorBoundary extends React.Component<
  { children: React.ReactNode; fallback: React.ComponentType<{ error: Error }> },
  { error: Error | null }
> {
  constructor(props) {
    super(props);
    this.state = { error: null };
  }

  static getDerivedStateFromError(error: Error) {
    return { error };
  }
typescript  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    // エラーログの送信
    console.error('React Error Boundary がエラーをキャッチ:', error, errorInfo);
  }

  componentDidMount() {
    // Unhandled Rejection のリスナーを追加
    window.addEventListener('unhandledrejection', this.handleUnhandledRejection);
  }

  componentWillUnmount() {
    window.removeEventListener('unhandledrejection', this.handleUnhandledRejection);
  }
typescript  handleUnhandledRejection = (event: PromiseRejectionEvent) => {
    event.preventDefault();
    this.setState({
      error: event.reason instanceof Error
        ? event.reason
        : new Error(String(event.reason))
    });
  };

  render() {
    if (this.state.error) {
      const FallbackComponent = this.props.fallback;
      return <FallbackComponent error={this.state.error} />;
    }

    return this.props.children;
  }
}

使用例

作成したフックとコンポーネントを組み合わせて使用します。

typescript// エラー表示コンポーネント
function ErrorFallback({ error }: { error: Error }) {
  return (
    <div style={{ padding: '20px', color: 'red' }}>
      <h2>エラーが発生しました</h2>
      <p>{error.message}</p>
      <button onClick={() => window.location.reload()}>
        ページを再読み込み
      </button>
    </div>
  );
}

// メインコンポーネント
function UserProfile({ userId }: { userId: number }) {
  const { data, error, loading } =
    useAsyncSafe(async () => {
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok)
        throw new Error(`HTTP ${response.status}`);
      return response.json();
    }, [userId]);

  if (loading) return <div>読み込み中...</div>;
  if (error) throw error; // Error Boundary でキャッチ

  return <div>ようこそ、{data.name}さん</div>;
}
typescript// アプリケーションのルート
function App() {
  return (
    <AsyncErrorBoundary fallback={ErrorFallback}>
      <UserProfile userId={123} />
    </AsyncErrorBoundary>
  );
}

実践例 3:Node.js サーバーでの実装

Express を使った Node.js サーバーでの包括的なエラーハンドリングを実装します。

グローバルエラーハンドラーの設定

サーバー起動時にグローバルなエラーハンドラーを設定します。

javascript// サーバーのエントリーポイント
import express from 'express';

const app = express();

// Unhandled Rejection のハンドラー
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);

  // ログサービスに送信
  logger.error('Unhandled Rejection', {
    reason: reason,
    stack: reason instanceof Error ? reason.stack : undefined
  });
javascript  // 開発環境では詳細を表示、本番環境では graceful shutdown
  if (process.env.NODE_ENV === 'production') {
    // graceful shutdown の実行
    gracefulShutdown();
  }
});

// Uncaught Exception のハンドラー
process.on('uncaughtException', (error) => {
  console.error('Uncaught Exception:', error);

  logger.error('Uncaught Exception', {
    error: error.message,
    stack: error.stack
  });

  // 即座にシャットダウン
  process.exit(1);
});

非同期ルートハンドラーのラッパー

Express のルートハンドラーで発生したエラーを自動的にキャッチする Wrapper を作成します。

javascript// asyncHandler ラッパー
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next); // エラーを次のミドルウェアに渡す
  };
}

// 使用例
app.get(
  '/api/users/:id',
  asyncHandler(async (req, res) => {
    const userId = req.params.id;

    // エラーが発生しても asyncHandler が自動的にキャッチ
    const user = await getUserById(userId);

    res.json(user);
  })
);

エラーハンドリングミドルウェア

すべてのエラーを処理する中央集権的なミドルウェアを実装します。

javascript// エラーハンドリングミドルウェア
app.use((error, req, res, next) => {
  // エラーのログ出力
  console.error('Error occurred:', {
    message: error.message,
    stack: error.stack,
    url: req.url,
    method: req.method,
    body: req.body
  });
javascript// エラーの種類に応じたレスポンス
if (error.name === 'ValidationError') {
  return res.status(400).json({
    error: {
      code: 'VALIDATION_ERROR',
      message: 'バリデーションエラーが発生しました',
      details: error.details,
    },
  });
}
javascript  if (error.name === 'UnauthorizedError') {
    return res.status(401).json({
      error: {
        code: 'UNAUTHORIZED',
        message: '認証が必要です'
      }
    });
  }

  // デフォルトのエラーレスポンス
  res.status(500).json({
    error: {
      code: 'INTERNAL_SERVER_ERROR',
      message: process.env.NODE_ENV === 'production'
        ? 'サーバーエラーが発生しました'
        : error.message
    }
  });
});

本番環境では詳細なエラーメッセージを隠し、開発環境では表示することでセキュリティとデバッグの両立を図ります。

Graceful Shutdown の実装

サーバーを安全にシャットダウンする処理を実装します。

javascript// Graceful Shutdown の実装
function gracefulShutdown() {
  console.log('Graceful shutdown を開始します...');

  server.close(() => {
    console.log('HTTP サーバーをクローズしました');

    // データベース接続のクローズ
    closeDatabase()
      .then(() => {
        console.log('データベース接続をクローズしました');
        process.exit(0);
      })
      .catch(err => {
        console.error('データベースクローズエラー:', err);
        process.exit(1);
      });
  });
javascript  // タイムアウト設定(30秒)
  setTimeout(() => {
    console.error('Graceful shutdown がタイムアウトしました');
    process.exit(1);
  }, 30000);
}

// SIGTERM シグナルの処理
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);

まとめ

本記事では、JavaScript の Unhandled Promise Rejection を根絶するための包括的な方法を解説しました。重要なポイントを振り返りましょう。

Unhandled Rejection を防ぐための基本原則

#原則実践方法
1すべての Promise にエラーハンドリングを付けるtry-catch または .catch() を必ず使用
2async 関数の呼び出しに注意するawait を付け、適切にエラーをキャッチ
3イベントハンドラー内の非同期処理を保護するWrapper 関数でエラーを自動キャッチ
4グローバルハンドラーを設定する最後の砦としての Error Boundary

推奨する設計パターン

本記事で紹介した 3 つの設計パターンは、それぞれ異なる状況で効果を発揮します。

Error Boundary パターン は、アプリケーション全体の安全網として機能します。すべての Unhandled Rejection を最終的にキャッチし、ログ出力やユーザー通知を一元管理できます。

Safe Async Wrapper パターン は、個々の非同期関数を保護し、リトライやデフォルト値の返却を自動化します。API 呼び出しなど、失敗する可能性のある処理に最適です。

Result Type パターン は、型安全性を重視する TypeScript プロジェクトで威力を発揮します。エラーを値として扱うことで、コンパイル時にエラーハンドリングの漏れを検出できます。

環境別の対策まとめ

ブラウザ環境では、window.addEventListener('unhandledrejection') でグローバルハンドラーを設定し、React の場合は Error Boundary コンポーネントと組み合わせましょう。

Node.js 環境では、process.on('unhandledRejection')process.on('uncaughtException') の両方を設定し、Graceful Shutdown を実装することが重要です。本番環境では特に、予期しないエラーでサーバーが停止しないよう、適切な対策が必要になります。

デバッグとモニタリング

Unhandled Rejection を防ぐだけでなく、発生したエラーを適切にログ出力し、監視することも重要です。エラーメッセージには以下の情報を含めましょう。

  • エラーコード(HTTP ステータス、カスタムエラーコードなど)
  • エラーメッセージ(具体的で検索可能な内容)
  • スタックトレース
  • 発生時刻とコンテキスト情報

これらの情報があれば、本番環境で発生したエラーの原因を迅速に特定できます。

次のステップ

本記事で学んだパターンを実際のプロジェクトに適用してみてください。最初は小さな機能から始め、徐々に範囲を広げていくと良いでしょう。

特に重要なのは、チーム全体でエラーハンドリングの方針を統一することです。コーディング規約に本記事で紹介したパターンを組み込み、レビュー時にエラーハンドリングの漏れをチェックする習慣をつけましょう。

Unhandled Rejection のない、堅牢なアプリケーションを構築できるよう応援しています。

関連リンク

本記事で扱ったトピックについて、さらに深く学びたい方は以下の公式ドキュメントやリソースをご参照ください。