JavaScript | 非同期処理:エラー処理・例外設計 – エラー再送出

JavaScript JavaScript
スポンサーリンク

「エラー再送出」を一言でいうと

エラー再送出(rethrow)は、
「一度 catch で受け取ったエラーを、“ここでは処理しきらないから、上のレイヤーにもう一回投げ直す” 行為 です。

非同期処理では特に、

・ここではログだけ取りたい
・ここでは共通処理だけしたい
・最終的な判断(画面遷移・ユーザー向け表示)はもっと上の層でやりたい

という場面がよくあります。

そのときに使うのが「再送出」です。
一度 catch して「何かする」、でも「握りつぶさずに上に渡す」——
このバランスを取るためのテクニックだと思ってください。


まずは同期処理で「再送出」のイメージをつかむ

catch したけど、ここでは決めきれない

シンプルな同期コードから見てみます。

function lowLevel() {
  throw new Error("一番下でエラー");
}

function middle() {
  try {
    lowLevel();
  } catch (err) {
    console.error("[middle] ログだけ取る:", err.message);
    throw err; // ← ここが「再送出」
  }
}

function top() {
  try {
    middle();
  } catch (err) {
    console.log("[top] ユーザー向け処理:", err.message);
  }
}

top();
JavaScript

流れとしてはこうです。

lowLevel でエラー発生
→ middle の try に飛ぶ
→ middle の catch で一度受け取る
→ ログを出す
throw err で上に投げ直す
→ top の catch で最終的な処理をする

ここで重要なのは、
middle は「ログを取る」という責務だけを果たし、
「どうユーザーに伝えるか」は top に任せている
ことです。

これが「再送出」の基本パターンです。
「ここで全部決めない。自分の責務だけ果たして、あとは上に任せる」。


非同期処理(async / await)でのエラー再送出

共通関数の中でログ+再送出

非同期処理だと、よくあるのが「共通 API 関数」の中での再送出です。

async function callApi(url, options = {}) {
  try {
    const response = await fetch(url, options);

    if (!response.ok) {
      const err = new Error("HTTP error " + response.status);
      console.error("[callApi] HTTP エラー", { url, status: response.status });
      throw err; // ここで throw しているが、これも「上に送る」意味
    }

    return await response.json();
  } catch (err) {
    console.error("[callApi] ネットワーク or その他のエラー", err);
    throw err; // ← ここが「再送出」
  }
}
JavaScript

この callApi は、こういう役割を持っています。

・fetch の実行
・HTTP ステータスのチェック
・ログ出力
・エラーを「握りつぶさずに」呼び出し元に伝える

呼び出し側はこう書けます。

async function loadUsers() {
  try {
    const data = await callApi("/api/users");
    renderUsers(data);
  } catch (err) {
    console.log("[loadUsers] ここでユーザー向けエラー表示を決める");
    showErrorMessage("ユーザーの取得に失敗しました。時間をおいて再度お試しください。");
  }
}
JavaScript

ここが重要です。
callApi の catch で「ログだけ取り、再送出する」ことで、
「技術的な共通処理」と「画面ごとの振る舞い」をきれいに分離できる
のです。


「再送出しない」と何がまずいのか

その場で握りつぶすと「上のレイヤー」が何も知らない

よくある悪いパターンがこれです。

async function callApi(url, options = {}) {
  try {
    const response = await fetch(url, options);
    if (!response.ok) {
      throw new Error("HTTP error " + response.status);
    }
    return await response.json();
  } catch (err) {
    console.error("[callApi] エラー:", err);
    // 何も throw しない
  }
}

async function loadUsers() {
  try {
    const data = await callApi("/api/users");
    renderUsers(data); // data が undefined かもしれない
  } catch (err) {
    // ここには来ない
    showErrorMessage("ユーザーの取得に失敗しました");
  }
}
JavaScript

callApi の中でエラーを catch してログを出したあと、
再送出していないので、loadUsers の catch には届きません。

結果として、

・画面側は「成功したのか失敗したのか」分からない
・data が undefined のまま renderUsers を呼んで別のエラーになる
・ユーザーには何もエラー表示が出ない

といった状態になりがちです。

ここが重要です。
「catch したら必ず何かしら throw し直すべき」という話ではありませんが、
“ここで完全に処理を終えるのか?” “上に知らせる必要はないのか?” を必ず意識する必要があります。
再送出を忘れると、「静かに失敗する」コードになりやすいです。


エラーを「別の型」に変えて再送出する

生の Error → カスタムエラーに変換して投げ直す

再送出は、単に throw err するだけではなく、
「別のエラーにラップして投げ直す」 こともよくあります。

例えば、fetch のエラーや HTTP エラーを、
ApiError というカスタムエラーに変換して上に渡したい場合です。

class ApiError extends Error {
  constructor(message, status, body) {
    super(message);
    this.name = "ApiError";
    this.status = status;
    this.body = body;
  }
}

async function callApi(url, options = {}) {
  try {
    const response = await fetch(url, options);
    const body = await response.json().catch(() => null);

    if (!response.ok) {
      const message =
        body?.error?.message || `HTTP error ${response.status}`;
      throw new ApiError(message, response.status, body);
    }

    return body;
  } catch (err) {
    if (err instanceof ApiError) {
      // すでに整形済みならそのまま再送出
      throw err;
    }

    console.error("[callApi] ネットワークエラーなど:", err);
    throw new ApiError("ネットワークエラーが発生しました", undefined, null);
  }
}
JavaScript

ここでは、

・HTTP エラー → ApiError に変換して throw
・その他のエラー(ネットワークなど) → ログを出してから、別の ApiError に変換して throw

という「変換+再送出」をしています。

呼び出し側は、こう扱えます。

async function loadUsers() {
  try {
    const body = await callApi("/api/users");
    renderUsers(body.data);
  } catch (err) {
    if (err instanceof ApiError) {
      showErrorMessage(err.message);
    } else {
      showErrorMessage("予期しないエラーが発生しました");
    }
  }
}
JavaScript

ここが重要です。
再送出は、「そのまま上に投げる」だけでなく、
「自分のレイヤーにふさわしいエラー型に変換してから上に渡す」ためにも使われます。
これによって、上のレイヤーは「扱いやすいエラー」だけを相手にすればよくなります。


「ここでは処理する」「ここでは再送出する」の線引き

責務ごとにレイヤーを分けて考える

エラー再送出を設計するときに大事なのは、
「この関数の責務はどこまでか?」をはっきりさせることです。

例えば、こんなレイヤー分けを考えます。

通信レイヤー(callApi など)
→ fetch、ステータスチェック、ログ、カスタムエラーへの変換
→ ここでは「ユーザー向け表示」はしない
→ エラーは再送出して、上に判断を任せる

アプリケーションレイヤー(loadUsers など)
→ どの API を呼ぶか、結果をどう UI に反映するか
→ ここでユーザー向けメッセージを決める
→ 必要ならさらに上(グローバルハンドラ)に再送出することも

グローバルレイヤー(最上位のエラーハンドラ)
→ 想定外のエラーをまとめてログに送る、エラーページに飛ばすなど

このとき、
通信レイヤーでは「再送出する」のが基本、
アプリケーションレイヤーでは「ここで完結させる」ことが多い、
というように線引きができます。

ここが重要です。
再送出するかどうかは、「このレイヤーで完結させるべきエラーか?」「上のレイヤーにも判断させたいか?」で決めます。
なんとなく throw し直すのではなく、“責務の境界” を意識して決めるのがポイントです。


非同期処理でよくある「再送出パターン」

パターン1:ログ+再送出

一番よく使うのがこれです。

async function someService() {
  try {
    // 何か非同期処理
  } catch (err) {
    logError("someService", err);
    throw err; // 上に伝える
  }
}
JavaScript

「ここではログだけ取りたい。
でも、エラー自体は上に知らせたい」というときの定番です。

パターン2:変換+再送出

生のエラーをカスタムエラーに変換してから投げ直すパターンです。

async function someService() {
  try {
    // 何か非同期処理
  } catch (err) {
    if (err instanceof SomeCustomError) {
      throw err;
    }
    throw new SomeCustomError("サービス内でのエラー", { cause: err });
  }
}
JavaScript

これにより、
上のレイヤーは SomeCustomError だけを相手にすればよくなります。

パターン3:一部だけ処理して、残りを再送出

例えば、「ローディング表示を消す」「一時的な状態をリセットする」など、
このレイヤーでしかできない後始末をしてから再送出するパターンです。

async function loadUsers() {
  setLoading(true);
  try {
    const data = await callApi("/api/users");
    renderUsers(data);
  } catch (err) {
    resetTemporaryState();
    throw err; // さらに上のグローバルハンドラに任せる
  } finally {
    setLoading(false);
  }
}
JavaScript

ここが重要です。
再送出は、「何もせずに投げる」のではなく、
“このレイヤーでしかできないこと” をやってから、
“このレイヤーでは決めきれないこと” を上に渡すための道具
です。


初心者として「エラー再送出」で本当に押さえてほしいこと

catch の中でエラーを「握りつぶす」と、
上のレイヤーは失敗に気づけなくなる。
ログだけ出して終わり、は危険なことが多い。

再送出は、
「ここではログや共通処理だけして、最終判断は上に任せる」
というためのテクニック。
throw err でそのまま投げ直すのも、
カスタムエラーに変換して投げ直すのも、どちらも「再送出」。

非同期処理では、
共通 API 関数(callApi など)でログ+変換+再送出を行い、
画面側の関数でユーザー向け表示を決める、
というレイヤー分けがとても相性がいい。

再送出するかどうかは、
「この関数の責務はどこまでか?」
「このエラーはここで完結させるべきか? 上にも知らせるべきか?」
という問いで決める。

ここが重要です。
コードを書いていて catch に入ったとき、
一度立ち止まって、
「このエラー、ここで終わらせていいのか? それとも、何かしてから上に投げ直すべきか?」
と自分に問いかけてみてください。

その問いに答える形で、
ログを取り、状態を片付け、ときにはカスタムエラーに変換し、
必要なら再送出する——
そうやって少しずつ、「エラーの流れをデザインできるコード」に近づいていきます。

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