JavaScript | 非同期処理:コールバック – エラーハンドリングの問題

JavaScript JavaScript
スポンサーリンク

コールバックとエラーハンドリングの関係(まず全体像)

コールバックを使った非同期処理では、
「エラーをどう扱うか」 が一気に難しくなります。

同期処理なら、

try {
  const result = doSomething();  // ここでエラーが起きたら catch に飛ぶ
  console.log(result);
} catch (e) {
  console.error("エラー発生:", e);
}
JavaScript

と書くだけで、doSomething の中で起きたエラーは全部 catch で受け止められます。

ところが、非同期コールバックになると、
「try/catch がそのままでは使えない」
「エラー処理があちこちに分散する」 という問題が出てきます。

ここが重要です。
コールバックスタイルの非同期処理では、
「エラーが発生するタイミングが“あとで”になる」ため、同期のときと同じ感覚でエラーを扱えない
これが本質です。


同期処理のエラーハンドリングはシンプル

try/catch で「上から下に」エラーが伝播する

同期処理のときは、こうなります。

function step1() {
  console.log("step1");
  throw new Error("step1 でエラー");
}

function main() {
  try {
    step1();          // ここでエラー
    console.log("ここには来ない");
  } catch (e) {
    console.error("捕まえたエラー:", e.message);
  }
}

main();
JavaScript

実行の流れは、

  1. main() 内の step1() が呼ばれる
  2. step1() の中で throw
  3. 呼び出し元の main()try/catch にエラーが伝わる
  4. catch で一括して処理できる

という感じで、「コールスタックを上方向に伝わっていく」 ようにエラーが扱えます。

この「上から下に処理を書いて、エラーは catch でまとめて扱う」というモデルが、
同期コードの気持ちよさです。


非同期コールバックで「同じことをしようとすると」壊れる

典型的なやってしまいがちなコード

次のような非同期コードを考えてみます。

try {
  setTimeout(() => {
    console.log("中でエラーを投げる");
    throw new Error("タイマー内のエラー");
  }, 1000);
} catch (e) {
  console.error("捕まえたエラー:", e.message);
}
JavaScript

直感的には「try の中で setTimeout 呼んでるから、エラーも catch で取れるのでは?」と思いがちですが、
実際にはこれは catch で捕まりません

理由を分解して見ていきます。

なぜ try/catch が効かないのか

try ブロックの中で実行されているのは、

setTimeout(() => { ... }, 1000);
JavaScript

という「setTimeout を呼ぶ行」までです。

この瞬間に起きていることは、

  • setTimeout に非同期コールバックを渡している
  • Web API に「1秒後にこの関数を実行して」とお願いしている
  • その直後に try ブロックを抜けて、catch も終了する

ということです。

1秒後に実行されるのは、
「すでに try/catch のスコープを抜けたあとの、全く別タイミングでのコールバック」です。

なので、その中で throw しても、
「囲んでいるはずの try/catch」がもう存在していない のです。

ここが重要です。
非同期コールバックの中で発生したエラーは、「その外側の try/catch では受け止められない」。
エラー処理をその場所で“個別に”考えないといけなくなる。


非同期コールバックの世界でよく使われる「エラーファースト」スタイル

Node.js でおなじみの「error, result」パターン

コールバックでエラー処理をするために、
昔からよく使われているのが「エラーファーストコールバック」という書き方です。

基本形はこうです。

function someAsyncTask(callback) {
  // 非同期に何かをして…
  const error = null;   // 何か問題があれば Error オブジェクトなどを入れる
  const result = 42;
  callback(error, result);
}
JavaScript

使う側はこうなります。

someAsyncTask((err, result) => {
  if (err) {
    console.error("エラー:", err);
    return;
  }

  console.log("成功:", result);
});
JavaScript

約束事はシンプルです。

  • コールバックの第1引数がエラー(なければ null
  • 第2引数以降が結果

「エラーがあれば err に何か入っているので、毎回最初にそれをチェックする」
というスタイルです。

ネストするとどうなるか(エラー処理版コールバック地獄)

複数の非同期処理を順番に行うとき、
それぞれでエラーを判定していくと、こうなります。

step1((err, result1) => {
  if (err) {
    console.error("step1 失敗:", err);
    return;
  }

  step2(result1, (err, result2) => {
    if (err) {
      console.error("step2 失敗:", err);
      return;
    }

    step3(result2, (err, result3) => {
      if (err) {
        console.error("step3 失敗:", err);
        return;
      }

      console.log("全部成功:", result3);
    });
  });
});
JavaScript

やっていることは単純ですが、

  • ネストが深くなる
  • if (err) が何度も出てくる
  • どのエラーがどのステップのものか、パッと見て追いづらい

という、「エラー処理版コールバック地獄」 みたいな状態になります。

ここが重要です。
コールバックスタイルは「どの箇所でも個別にエラーを渡せる」一方で、「全体をまたいで一括管理する」のがとても面倒になる のです。


非同期コールバックでのエラーハンドリングが難しい理由を整理する

理由1:try/catch がそのまま使えない

さっき見たように、非同期コールバックで発生したエラーは、
コールバックを登録した時点の try/catch では捕まえられません。

例:

try {
  someAsync((err, result) => {
    // ここで throw しても、外側の try/catch には届かない
    if (err) throw err;
  });
} catch (e) {
  console.error("これでは捕まえられない");
}
JavaScript

エラーが起きるタイミングが「後から」だからです。

理由2:各段で if (err) を書き続ける必要がある

非同期処理が増えるたびに、

  • 「ここでもエラーかもしれない」
  • 「ここでもエラーかもしれない」

と考えながら、
毎回 if (err) { ... } を書かなければなりません。

その結果、

  • 成功時の処理の流れが見えづらくなる
  • エラー処理がコピー&ペーストされ、微妙に違う挙動になりがち
  • ある箇所だけ return を忘れるなどのミスが混入しやすい

といった問題が起きます。

理由3:共通の「最後の受け皿」を作りにくい

同期の try/catch のように、

「どこでエラーが起こっても、最後にここでまとめて処理したい」

ということをするのが、コールバックだけだとかなり難しいです。

あちこちに散らばった if (err) のどこかで return されて終わってしまい、
「共通の片付け処理」や「ログ出力」が抜ける、などが起こりやすくなります。

ここが重要です。
非同期コールバックの世界では、「エラーはその場その場で個別に処理しがち」になり、
「全体の流れに対する統一的なエラーハンドリング」がやりにくい構造になっている

これが問題の本質です。


それでもコールバックでエラー処理を頑張る場合の工夫

「共通のエラーハンドラ」を自分で用意する

完全に救われるわけではありませんが、
少しマシにする工夫として「共通のエラーハンドラ」を用意する方法があります。

function handleError(err, message) {
  if (err) {
    console.error(message, err);
    return true;  // エラーがあった
  }
  return false;   // 問題なし
}

step1((err, result1) => {
  if (handleError(err, "step1 失敗")) return;

  step2(result1, (err, result2) => {
    if (handleError(err, "step2 失敗")) return;

    step3(result2, (err, result3) => {
      if (handleError(err, "step3 失敗")) return;

      console.log("全部成功:", result3);
    });
  });
});
JavaScript

これで、「エラー時に最低限やること(ログ出力など)」を共通化できます。
とはいえネスト問題は依然として残っており、根本解決にはなっていません。

エラーを「最終コールバック」に集約する

もうひとつのパターンは、
「すべての処理が終わったら呼ばれる“最終コールバック”」を用意して、そこにエラーを集約する方法です。

function doAllSteps(finalCallback) {
  step1((err, result1) => {
    if (err) return finalCallback(err);

    step2(result1, (err, result2) => {
      if (err) return finalCallback(err);

      step3(result2, (err, result3) => {
        if (err) return finalCallback(err);

        finalCallback(null, result3);  // 成功
      });
    });
  });
}

doAllSteps((err, finalResult) => {
  if (err) {
    console.error("どこかで失敗:", err);
    return;
  }
  console.log("全部成功:", finalResult);
});
JavaScript

これなら、「最終結果の受け皿」だけを意識しておけばよくなります。
ただし内部ではやはり if (err) return finalCallback(err); が何度も登場し、
ネスト構造自体もそのままです。

ここが重要です。
工夫すれば一応なんとかなるけれど、
「エラー処理のためのコード量」と「ネストの複雑さ」はどうしても大きくなりがち
という限界があります。


まとめ:コールバックのエラーハンドリング問題をどう理解するか

コールバックスタイルの非同期処理における「エラーハンドリングの問題」を一言でまとめると、

「エラーが“いつ”発生するかが後ろにずれるせいで、同期のように try/catch で一括処理しづらく、
各コールバックの中に if (err) を書きまくる構造になり、コードがネストして散らばりやすい」

ということです。

押さえておきたいポイントは次のとおりです。

非同期コールバック内のエラーは、外側の try/catch には届かない(タイミングが違うため)。
そのため、各コールバックで if (err) を書いて個別に処理する必要が出てくる。
依存する非同期処理が増えるほど、「エラー処理付きネスト」のピラミッドができあがり、可読性・保守性が下がる。
共通のエラーハンドラや最終コールバックで多少マシにはできるが、「根本的なつらさ」は残る。

そして、この「つらさ」を解消するために登場したのが、
Promise の catch や async/await の try/catch です。

次のステップとして、

同じ処理をコールバックだけで書いた場合
Promise + then / catch で書いた場合
async/await + try/catch で書いた場合

を並べて比べてみると、
「エラーハンドリングの書きやすさの差」 がはっきり見えてきます。

そのとき、今日話した

「なぜコールバックだとエラー処理がバラバラになりがちなのか」
「なぜ一箇所でまとめて扱いにくいのか」

を思い出すと、
Promise / async/await のありがたみが、ただの新機能ではなく
「エラーハンドリングの地獄からの脱出手段」 として、しっかり腑に落ちてくるはずです。

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