コールバックとエラーハンドリングの関係(まず全体像)
コールバックを使った非同期処理では、
「エラーをどう扱うか」 が一気に難しくなります。
同期処理なら、
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実行の流れは、
main()内のstep1()が呼ばれるstep1()の中でthrow- 呼び出し元の
main()のtry/catchにエラーが伝わる 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 のありがたみが、ただの新機能ではなく
「エラーハンドリングの地獄からの脱出手段」 として、しっかり腑に落ちてくるはずです。
