はじめに

エラーハンドリングの基本構文(try-catch、Promise.catch)を理解していても、実際のアプリケーション開発では「どこでエラーをキャッチすべきか」「ユーザーにどう通知すべきか」「複数のAPIエラーをどう集約するか」といった設計上の課題に直面します。

本記事では、JavaScriptエラーハンドリングの実践パターンに焦点を当て、以下の内容を解説します。

  • カスタムエラークラスの設計と階層化
  • 非同期処理でのエラー集約パターン
  • グローバルエラーハンドラの実装
  • ユーザー通知UI(トースト通知)の実装例
  • エラーロギングとモニタリング戦略
  • 実務で役立つベストプラクティス

基本的なtry-catch構文やPromiseのエラー処理については、関連記事「JavaScriptのエラーハンドリングの基本と実践」を参照してください。

エラーハンドリングの設計原則

エラー処理の責任分離

大規模なアプリケーションでは、エラー処理を適切な層に分離することが重要です。以下の図は、エラー処理の責任を階層化した設計パターンを示しています。

flowchart TB
    subgraph UI層
        A[コンポーネント] --> B[ユーザー通知]
    end
    subgraph ビジネスロジック層
        C[ユースケース] --> D[エラー変換]
    end
    subgraph データアクセス層
        E[API通信] --> F[HTTPエラー検出]
    end
    
    F -->|NetworkError| D
    D -->|AppError| B
    
    G[グローバルハンドラ] -.->|未処理エラー| B
責任 処理内容
UI層 ユーザー通知 エラーメッセージ表示、リトライUI
ビジネスロジック層 エラー変換 技術的エラーをビジネスエラーに変換
データアクセス層 エラー検出 HTTPエラー、ネットワークエラーの検出
グローバルハンドラ 最終防衛 未処理エラーのキャッチとログ

「投げるか、処理するか」の判断基準

エラーが発生した際、その場で処理するか上位に投げるかは、以下の基準で判断します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 判断基準1: そのエラーを適切に処理できる情報を持っているか

// 悪い例: 情報不足のまま処理してしまう
async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    return await response.json();
  } catch (error) {
    return null; // 呼び出し元はエラーの原因を知れない
  }
}

// 良い例: 適切なエラー情報を付与して上位に投げる
async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new HttpError(response.status, "ユーザー取得に失敗しました");
    }
    return await response.json();
  } catch (error) {
    if (error instanceof HttpError) {
      throw error; // すでに適切なエラーならそのまま投げる
    }
    throw new NetworkError("ネットワーク接続を確認してください", error);
  }
}

カスタムエラークラスの設計

エラークラスの階層設計

実務では、アプリケーション全体で一貫したエラー構造を持つために、基底クラスから派生させる設計が有効です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 基底エラークラス
class AppError extends Error {
  constructor(message, options = {}) {
    super(message);
    this.name = this.constructor.name;
    this.code = options.code || "UNKNOWN_ERROR";
    this.statusCode = options.statusCode || 500;
    this.isOperational = options.isOperational ?? true;
    this.context = options.context || {};
    this.timestamp = new Date().toISOString();
    
    // V8エンジン向けスタックトレース最適化
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, this.constructor);
    }
  }

  toJSON() {
    return {
      name: this.name,
      message: this.message,
      code: this.code,
      statusCode: this.statusCode,
      context: this.context,
      timestamp: this.timestamp,
    };
  }

  toUserMessage() {
    return "予期しないエラーが発生しました";
  }
}

isOperationalプロパティは、予期されたエラー(ユーザー入力エラー、API応答エラー)と予期しないエラー(プログラムバグ)を区別するために使用します。

用途別エラークラスの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// バリデーションエラー
class ValidationError extends AppError {
  constructor(message, field = null) {
    super(message, {
      code: "VALIDATION_ERROR",
      statusCode: 400,
      context: { field },
    });
  }

  toUserMessage() {
    return this.message; // バリデーションメッセージはそのまま表示可能
  }
}

// HTTP通信エラー
class HttpError extends AppError {
  constructor(statusCode, message, originalError = null) {
    const statusMessages = {
      400: "リクエストに問題があります",
      401: "ログインが必要です",
      403: "アクセス権限がありません",
      404: "リソースが見つかりません",
      429: "リクエストが多すぎます。しばらくお待ちください",
      500: "サーバーで問題が発生しました",
      502: "サーバーが応答していません",
      503: "サービスが一時的に利用できません",
    };

    super(message || statusMessages[statusCode] || "通信エラーが発生しました", {
      code: `HTTP_${statusCode}`,
      statusCode,
      context: { originalError: originalError?.message },
    });
  }

  toUserMessage() {
    const userMessages = {
      401: "セッションが切れました。再度ログインしてください",
      403: "この操作を行う権限がありません",
      404: "お探しのデータが見つかりませんでした",
      429: "しばらく時間をおいてから再度お試しください",
      500: "サーバーで問題が発生しました。時間をおいて再度お試しください",
    };
    return userMessages[this.statusCode] || "通信中にエラーが発生しました";
  }
}

// ネットワーク接続エラー
class NetworkError extends AppError {
  constructor(message = "ネットワーク接続に失敗しました", originalError = null) {
    super(message, {
      code: "NETWORK_ERROR",
      statusCode: 0,
      context: { originalError: originalError?.message },
    });
  }

  toUserMessage() {
    return "インターネット接続を確認してください";
  }
}

// ビジネスロジックエラー
class BusinessError extends AppError {
  constructor(message, code) {
    super(message, {
      code,
      statusCode: 422,
    });
  }

  toUserMessage() {
    return this.message;
  }
}

エラークラスの使用例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// APIラッパー関数での使用
async function apiRequest(endpoint, options = {}) {
  try {
    const response = await fetch(`/api${endpoint}`, {
      headers: { "Content-Type": "application/json" },
      ...options,
    });

    if (!response.ok) {
      throw new HttpError(response.status);
    }

    return await response.json();
  } catch (error) {
    if (error instanceof AppError) {
      throw error;
    }
    // fetch自体の失敗(ネットワークエラー)
    throw new NetworkError("サーバーに接続できませんでした", error);
  }
}

// ビジネスロジックでの使用
async function purchaseProduct(productId, quantity) {
  const product = await apiRequest(`/products/${productId}`);
  
  if (product.stock < quantity) {
    throw new BusinessError(
      `在庫が不足しています(残り${product.stock}個)`,
      "INSUFFICIENT_STOCK"
    );
  }
  
  return apiRequest("/orders", {
    method: "POST",
    body: JSON.stringify({ productId, quantity }),
  });
}

非同期処理でのエラー集約パターン

Promise.allSettledによる部分成功の処理

複数のAPI呼び出しで、一部が失敗しても処理を継続したい場合に有効なパターンです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class AggregatedResult {
  constructor() {
    this.successes = [];
    this.failures = [];
  }

  addSuccess(value, key = null) {
    this.successes.push({ key, value });
  }

  addFailure(error, key = null) {
    this.failures.push({ key, error });
  }

  get hasFailures() {
    return this.failures.length > 0;
  }

  get allFailed() {
    return this.successes.length === 0 && this.failures.length > 0;
  }

  get partialSuccess() {
    return this.successes.length > 0 && this.failures.length > 0;
  }
}

async function fetchMultipleResources(resourceIds) {
  const result = new AggregatedResult();
  
  const promises = resourceIds.map((id) =>
    apiRequest(`/resources/${id}`).then((data) => ({ id, data }))
  );
  
  const settled = await Promise.allSettled(promises);
  
  settled.forEach((outcome, index) => {
    const id = resourceIds[index];
    if (outcome.status === "fulfilled") {
      result.addSuccess(outcome.value.data, id);
    } else {
      result.addFailure(outcome.reason, id);
    }
  });
  
  return result;
}

// 使用例
async function loadDashboard() {
  const resourceIds = ["user", "notifications", "stats"];
  const result = await fetchMultipleResources(resourceIds);
  
  if (result.allFailed) {
    showError("データの読み込みに失敗しました");
    return;
  }
  
  if (result.partialSuccess) {
    showWarning("一部のデータを読み込めませんでした");
    result.failures.forEach(({ key, error }) => {
      console.warn(`${key}の取得に失敗:`, error.message);
    });
  }
  
  // 成功したデータで画面を構築
  renderDashboard(result.successes);
}

順次実行でのエラー収集

複数の処理を順次実行しながら、エラーを収集して最後にまとめて報告するパターンです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
async function processItemsWithErrorCollection(items, processor) {
  const errors = [];
  const results = [];

  for (const item of items) {
    try {
      const result = await processor(item);
      results.push({ item, result, success: true });
    } catch (error) {
      errors.push({ item, error, success: false });
      // エラーが発生しても継続
    }
  }

  return {
    results,
    errors,
    summary: {
      total: items.length,
      succeeded: results.length,
      failed: errors.length,
    },
  };
}

// 使用例: ファイルの一括アップロード
async function uploadFiles(files) {
  const outcome = await processItemsWithErrorCollection(files, async (file) => {
    const formData = new FormData();
    formData.append("file", file);
    return apiRequest("/upload", { method: "POST", body: formData });
  });

  if (outcome.errors.length > 0) {
    const failedNames = outcome.errors.map((e) => e.item.name).join(", ");
    showWarning(`以下のファイルのアップロードに失敗しました: ${failedNames}`);
  }

  showSuccess(`${outcome.summary.succeeded}件のファイルをアップロードしました`);
  return outcome;
}

グローバルエラーハンドラの実装

ブラウザ環境でのグローバルハンドラ

アプリケーション全体で未処理のエラーをキャッチし、ログ送信やユーザー通知を行うパターンです。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
class GlobalErrorHandler {
  constructor(options = {}) {
    this.errorQueue = [];
    this.isProcessing = false;
    this.options = {
      maxQueueSize: 50,
      flushInterval: 5000,
      onError: options.onError || console.error,
      onUserNotify: options.onUserNotify || (() => {}),
      shouldReport: options.shouldReport || (() => true),
    };
    
    this.initialize();
  }

  initialize() {
    // 同期エラーのキャッチ
    window.addEventListener("error", (event) => {
      this.handleError(event.error, {
        type: "uncaught",
        filename: event.filename,
        lineno: event.lineno,
        colno: event.colno,
      });
    });

    // 未処理のPromise拒否をキャッチ
    window.addEventListener("unhandledrejection", (event) => {
      this.handleError(event.reason, {
        type: "unhandledrejection",
      });
      event.preventDefault();
    });

    // 定期的なエラーログ送信
    setInterval(() => this.flushErrors(), this.options.flushInterval);

    // ページ離脱時にエラーを送信
    window.addEventListener("beforeunload", () => {
      this.flushErrors(true);
    });
  }

  handleError(error, context = {}) {
    const errorInfo = this.normalizeError(error, context);
    
    this.options.onError(errorInfo);

    // 運用エラー(予期されたエラー)はユーザーに通知
    if (error instanceof AppError && error.isOperational) {
      this.options.onUserNotify(error.toUserMessage());
    } else {
      // 予期しないエラーは汎用メッセージ
      this.options.onUserNotify("予期しないエラーが発生しました");
    }

    // エラーレポートのキューに追加
    if (this.options.shouldReport(errorInfo)) {
      this.queueError(errorInfo);
    }
  }

  normalizeError(error, context) {
    if (error instanceof AppError) {
      return {
        ...error.toJSON(),
        ...context,
      };
    }

    return {
      name: error?.name || "UnknownError",
      message: error?.message || String(error),
      stack: error?.stack,
      ...context,
      timestamp: new Date().toISOString(),
    };
  }

  queueError(errorInfo) {
    if (this.errorQueue.length >= this.options.maxQueueSize) {
      this.errorQueue.shift();
    }
    this.errorQueue.push(errorInfo);
  }

  async flushErrors(sync = false) {
    if (this.isProcessing || this.errorQueue.length === 0) {
      return;
    }

    this.isProcessing = true;
    const errors = [...this.errorQueue];
    this.errorQueue = [];

    try {
      if (sync && navigator.sendBeacon) {
        // ページ離脱時は sendBeacon を使用
        navigator.sendBeacon(
          "/api/errors",
          JSON.stringify({ errors })
        );
      } else {
        await fetch("/api/errors", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ errors }),
        });
      }
    } catch (sendError) {
      // エラー送信の失敗はキューに戻す
      this.errorQueue.unshift(...errors);
      console.warn("エラーログの送信に失敗:", sendError);
    } finally {
      this.isProcessing = false;
    }
  }
}

// 初期化
const errorHandler = new GlobalErrorHandler({
  onUserNotify: (message) => {
    showToast({ type: "error", message });
  },
  shouldReport: (error) => {
    // 開発環境ではレポートしない
    return process.env.NODE_ENV === "production";
  },
});

ユーザー通知UIの実装

トースト通知システム

エラーをユーザーにわかりやすく通知するトースト通知の実装例です。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
class ToastNotification {
  constructor(container = document.body) {
    this.container = container;
    this.toasts = [];
    this.createStyles();
    this.createContainer();
  }

  createStyles() {
    const style = document.createElement("style");
    style.textContent = `
      .toast-container {
        position: fixed;
        top: 20px;
        right: 20px;
        z-index: 9999;
        display: flex;
        flex-direction: column;
        gap: 10px;
        max-width: 400px;
      }
      .toast {
        padding: 16px 20px;
        border-radius: 8px;
        color: white;
        font-size: 14px;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
        display: flex;
        align-items: flex-start;
        gap: 12px;
        animation: slideIn 0.3s ease;
      }
      .toast-error { background: #dc3545; }
      .toast-warning { background: #ffc107; color: #212529; }
      .toast-success { background: #28a745; }
      .toast-info { background: #17a2b8; }
      .toast-content { flex: 1; }
      .toast-title { font-weight: bold; margin-bottom: 4px; }
      .toast-message { opacity: 0.9; }
      .toast-close {
        background: none;
        border: none;
        color: inherit;
        cursor: pointer;
        opacity: 0.7;
        font-size: 18px;
        padding: 0;
        line-height: 1;
      }
      .toast-close:hover { opacity: 1; }
      .toast-actions { margin-top: 12px; display: flex; gap: 8px; }
      .toast-action {
        padding: 6px 12px;
        border: 1px solid currentColor;
        border-radius: 4px;
        background: transparent;
        color: inherit;
        cursor: pointer;
        font-size: 13px;
      }
      @keyframes slideIn {
        from { transform: translateX(100%); opacity: 0; }
        to { transform: translateX(0); opacity: 1; }
      }
      @keyframes slideOut {
        from { transform: translateX(0); opacity: 1; }
        to { transform: translateX(100%); opacity: 0; }
      }
    `;
    document.head.appendChild(style);
  }

  createContainer() {
    this.toastContainer = document.createElement("div");
    this.toastContainer.className = "toast-container";
    this.container.appendChild(this.toastContainer);
  }

  show(options) {
    const {
      type = "info",
      title = "",
      message,
      duration = 5000,
      actions = [],
    } = options;

    const toast = document.createElement("div");
    toast.className = `toast toast-${type}`;
    
    const icons = {
      error: "&#10006;",
      warning: "&#9888;",
      success: "&#10004;",
      info: "&#8505;",
    };

    toast.innerHTML = `
      <span class="toast-icon">${icons[type]}</span>
      <div class="toast-content">
        ${title ? `<div class="toast-title">${title}</div>` : ""}
        <div class="toast-message">${message}</div>
        ${actions.length > 0 ? `
          <div class="toast-actions">
            ${actions.map((action) => 
              `<button class="toast-action" data-action="${action.id}">${action.label}</button>`
            ).join("")}
          </div>
        ` : ""}
      </div>
      <button class="toast-close">&times;</button>
    `;

    // イベントリスナー
    toast.querySelector(".toast-close").addEventListener("click", () => {
      this.remove(toast);
    });

    actions.forEach((action) => {
      const btn = toast.querySelector(`[data-action="${action.id}"]`);
      if (btn) {
        btn.addEventListener("click", () => {
          action.handler();
          this.remove(toast);
        });
      }
    });

    this.toastContainer.appendChild(toast);
    this.toasts.push(toast);

    if (duration > 0) {
      setTimeout(() => this.remove(toast), duration);
    }

    return toast;
  }

  remove(toast) {
    if (!toast.parentNode) return;
    
    toast.style.animation = "slideOut 0.3s ease forwards";
    setTimeout(() => {
      toast.remove();
      this.toasts = this.toasts.filter((t) => t !== toast);
    }, 300);
  }

  error(message, options = {}) {
    return this.show({ type: "error", message, ...options });
  }

  warning(message, options = {}) {
    return this.show({ type: "warning", message, ...options });
  }

  success(message, options = {}) {
    return this.show({ type: "success", message, ...options });
  }

  info(message, options = {}) {
    return this.show({ type: "info", message, ...options });
  }
}

// グローバルインスタンス
const toast = new ToastNotification();

// 使用例
function showError(message, retryHandler = null) {
  const actions = retryHandler
    ? [{ id: "retry", label: "再試行", handler: retryHandler }]
    : [];
  
  toast.error(message, {
    title: "エラーが発生しました",
    actions,
    duration: retryHandler ? 0 : 5000, // リトライボタンがある場合は自動で消さない
  });
}

エラータイプ別の通知制御

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function notifyError(error, options = {}) {
  if (error instanceof ValidationError) {
    toast.warning(error.toUserMessage(), {
      title: "入力エラー",
      duration: 4000,
    });
    return;
  }

  if (error instanceof NetworkError) {
    toast.error(error.toUserMessage(), {
      title: "接続エラー",
      actions: [
        {
          id: "retry",
          label: "再試行",
          handler: options.onRetry || (() => window.location.reload()),
        },
      ],
      duration: 0,
    });
    return;
  }

  if (error instanceof HttpError) {
    if (error.statusCode === 401) {
      toast.warning("セッションが切れました", {
        title: "再ログインが必要です",
        actions: [
          { id: "login", label: "ログイン", handler: () => window.location.href = "/login" },
        ],
        duration: 0,
      });
      return;
    }

    toast.error(error.toUserMessage(), { duration: 5000 });
    return;
  }

  // 予期しないエラー
  toast.error("予期しないエラーが発生しました", {
    title: "エラー",
    duration: 5000,
  });
}

エラーハンドリングのベストプラクティス

1. エラー境界パターン

UIコンポーネント単位でエラーを隔離し、一部の失敗が全体に影響しないようにするパターンです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
async function withErrorBoundary(fn, fallback = null, onError = null) {
  try {
    return await fn();
  } catch (error) {
    if (onError) {
      onError(error);
    }
    return fallback;
  }
}

// 使用例: ダッシュボードの各セクションを独立して処理
async function renderDashboard() {
  const [userData, statsData, notificationsData] = await Promise.all([
    withErrorBoundary(
      () => fetchUserProfile(),
      { name: "ゲスト" }, // フォールバック値
      (error) => console.warn("プロファイル取得失敗:", error)
    ),
    withErrorBoundary(
      () => fetchStats(),
      { views: 0, sales: 0 },
      (error) => console.warn("統計取得失敗:", error)
    ),
    withErrorBoundary(
      () => fetchNotifications(),
      [],
      (error) => console.warn("通知取得失敗:", error)
    ),
  ]);

  renderUserSection(userData);
  renderStatsSection(statsData);
  renderNotificationsSection(notificationsData);
}

2. リトライ戦略の実装

ネットワークエラーなど一時的な障害に対して、指数バックオフを用いたリトライを実装するパターンです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
async function withRetry(fn, options = {}) {
  const {
    maxAttempts = 3,
    baseDelay = 1000,
    maxDelay = 10000,
    shouldRetry = (error) => error instanceof NetworkError,
    onRetry = () => {},
  } = options;

  let lastError;
  
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      
      if (attempt === maxAttempts || !shouldRetry(error)) {
        throw error;
      }
      
      // 指数バックオフ + ジッター
      const delay = Math.min(
        baseDelay * Math.pow(2, attempt - 1) + Math.random() * 1000,
        maxDelay
      );
      
      onRetry(attempt, delay, error);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
  
  throw lastError;
}

// 使用例
async function fetchWithAutoRetry(url) {
  return withRetry(
    () => fetch(url).then((res) => {
      if (!res.ok) throw new HttpError(res.status);
      return res.json();
    }),
    {
      maxAttempts: 3,
      shouldRetry: (error) => {
        // ネットワークエラーまたは5xx系のみリトライ
        return error instanceof NetworkError ||
          (error instanceof HttpError && error.statusCode >= 500);
      },
      onRetry: (attempt, delay) => {
        console.log(`リトライ ${attempt}回目(${delay}ms後)...`);
      },
    }
  );
}

3. 開発環境と本番環境でのエラー表示切り替え

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function handleError(error, context = {}) {
  const isDev = process.env.NODE_ENV === "development";

  if (isDev) {
    // 開発環境: 詳細情報を表示
    console.group("Error Details");
    console.error("Error:", error);
    console.log("Context:", context);
    console.log("Stack:", error.stack);
    console.groupEnd();
    
    toast.error(error.message, {
      title: `${error.name} (開発モード)`,
      duration: 0,
    });
  } else {
    // 本番環境: ユーザーフレンドリーなメッセージのみ
    console.error(`[${error.code || "ERROR"}] ${error.message}`);
    
    if (error instanceof AppError) {
      toast.error(error.toUserMessage());
    } else {
      toast.error("予期しないエラーが発生しました");
    }
  }
}

4. 非同期イベントハンドラでのエラー処理

イベントハンドラ内の非同期処理は、try-catchで確実にラップする必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 悪い例: エラーが握りつぶされる可能性
button.addEventListener("click", async () => {
  const data = await fetchData(); // エラーが発生しても無視される
  updateUI(data);
});

// 良い例: エラーを適切に処理
button.addEventListener("click", async () => {
  try {
    const data = await fetchData();
    updateUI(data);
  } catch (error) {
    notifyError(error);
  }
});

// さらに良い例: ヘルパー関数で共通化
function asyncHandler(fn) {
  return async (...args) => {
    try {
      return await fn(...args);
    } catch (error) {
      notifyError(error);
    }
  };
}

button.addEventListener("click", asyncHandler(async () => {
  const data = await fetchData();
  updateUI(data);
}));

まとめ

JavaScriptのエラーハンドリングを実践的なパターンとともに解説しました。

パターン 用途 効果
カスタムエラークラス エラーの分類と情報付与 デバッグ効率化、適切な通知
エラー集約 複数処理の部分成功 ユーザー体験の向上
グローバルハンドラ 未処理エラーのキャッチ アプリ安定性、ログ収集
トースト通知 ユーザーへのフィードバック 分かりやすいエラー表示
リトライ戦略 一時的障害への対応 耐障害性の向上

エラーハンドリングは、アプリケーションの信頼性ユーザー体験を左右する重要な設計領域です。基本構文を理解したうえで、適切なパターンを状況に応じて選択できるようになることで、堅牢なアプリケーションを構築できます。

参考リンク