Contents
Promise の状態とエラー伝搬の基礎
Promise は非同期処理を抽象化したオブジェクトで、pending → fulfilled / rejected と 2 段階に遷移します。実務で堅牢なエラーハンドリングを行うには、各状態が次の then や catch にどのように伝搬するかを正しく理解しておくことが不可欠です。本セクションでは、Promise の基本的なライフサイクルと例外が自動的に reject へ変換される仕組みを解説します。
- fulfilled:
resolveが呼び出されたとき。thenの第一引数に渡したコールバックが実行され、戻り値は次の Promise に伝搬します。 - rejected:
rejectが呼ばれたか、then/catch内で例外がスローされたとき。最も近いcatch(またはthenの第二引数)へ制御が移ります。
MDN の解説[^1] と ECMAScript 仕様[^2] によれば、
thenコールバック内で throw が発生すると、その瞬間に生成された Promise は自動的に rejected 状態となり、チェーン上の次のcatchが呼び出されます。したがって「throwがcatchされない」ケースは、例外が Promise の外側(例えば同期コードで直接throw)で発生したときに限られます。
|
1 2 3 4 5 6 7 |
fetch('/api/data') .then(res => { if (!res.ok) throw new Error('Network error'); // → Promise が reject に変換される return res.json(); }) .catch(err => console.error('Handled error:', err)); |
then と catch の正しい組み合わせ
このセクションでは、then と catch を組み合わせたときのエラー伝搬パターンを具体例で示し、意図しない抜け漏れを防ぐコツを紹介します。
1. 基本的なエラーパイプライン
then の中でスローされた例外は同じチェーン内の次の catch が捕捉します。返却した Promise をそのまま続けることが重要です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
doSomething() .then(result => { // 何らかの検証に失敗した場合 if (!result.ok) throw new Error('Invalid data'); return result.value; }) .catch(err => { console.warn('Validation failed:', err); // 必要なら代替値を返すことで下流を継続させる return getDefault(); }) .then(finalValue => console.log('Proceed with', finalValue)); |
2. then の第二引数は非推奨
then(onFulfilled, onRejected) の形でエラーハンドリングを書くこともできますが、可読性と一貫性の観点から catch を別行に書く方が好ましいと多くのガイドラインで推奨されています[^3]。
|
1 2 3 4 5 6 7 8 9 10 11 |
// 推奨されない例(エラーハンドリングが分散しやすい) promise.then( data => use(data), err => console.error('Error:', err) ); // 推奨される例 promise .then(data => use(data)) .catch(err => console.error('Error:', err)); |
チェーン途中での catch 活用法
実務では処理を段階的に分割し、各ステージごとに専用のエラーハンドラを配置することが多いです。このセクションでは「最も近い catch が優先される」という特性を利用した設計パターンを解説します。
1. ステージ別ハンドリング
|
1 2 3 4 5 6 7 8 9 |
fetchUser() .then(validateUser) // ユーザー取得 → バリデーション .catch(err => { // A/B 両方のエラーをまとめて処理 console.error('User step error:', err); return null; // 後続は安全にスキップできる }) .then(saveToDb) // `null` が渡っても DB 書き込みは行わない実装が必要 .catch(err => console.error('DB error:', err)); |
- 利点: 前段で捕捉したエラーは下流へ伝搬しないため、不要な処理を回避できる。
- 注意点:
catch内で再スローしない限り、後続のチェーンは 成功扱い(thenが実行)になる。
2. フォールバック値によるリカバリ
エラー発生時に代替データを返すことで、以降のロジックを中断せずに進められます。特に外部 API が不安定な場合に有効です。
|
1 2 3 4 |
fetchConfig() .catch(() => ({ timeout: 5000, retry: 3 })) // デフォルト設定でリカバリ .then(cfg => startService(cfg)); |
async/await と try‑catch の実務パターン
async 関数は内部で await した Promise が reject されたときに例外をスローします。これを従来の try‑catch で捕捉すれば、同期コードと同様の可読性が得られます。
1. 基本形
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
async function loadProfile(id) { try { const user = await getUserFromDb(id); // DB エラーはここで捕捉 const resp = await fetch(`/api/profile/${id}`); if (!resp.ok) throw new Error('API error'); return await resp.json(); // 正常結果を返す } catch (err) { logError(err, 'loadProfile'); // ログは統一フォーマットで出力 // 必要に応じて代替データを返すか、再スローして上位へ委譲 throw err; } } |
- 可読性:
awaitとtry‑catchの組み合わせはエラーロジックが分散せず、一箇所に集約できる。 - 粒度の調整: 必要なら内部でさらに
try‑catchを入れ、外部 API と DB それぞれの失敗を個別にハンドリング可能。
2. エラーログの標準化
実務ではログの形式を統一するとモニタリングが楽になります。以下は JSON 形式で出力する例です。
|
1 2 3 4 5 6 7 8 9 |
function logError(err, context) { console.error(JSON.stringify({ message: err.message, stack: err.stack, context, timestamp: new Date().toISOString() })); } |
Node.js 環境での未捕捉エラー対策
Node.js では 未処理の Promise リジェクト がプロセス終了につながることがあります。サービス停止を防ぐため、グローバルハンドラの設定は必須です。
1. unhandledRejection のハンドリング
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
process.on('unhandledRejection', (reason, promise) => { console.error(JSON.stringify({ message: reason instanceof Error ? reason.message : String(reason), stack: reason instanceof Error ? reason.stack : null, timestamp: new Date().toISOString(), origin: 'unhandledRejection' })); if (process.env.NODE_ENV === 'production') { // 本番環境では安全に再起動させる方針が一般的 setTimeout(() => process.exit(1), 0); } }); |
- 開発時はスタックトレースをコンソールに出力し、原因特定に役立てます。
- 本番時は JSON ログを外部モニタリング(例:Datadog, Sentry)へ送信し、プロセスを速やかに再起動させることでダウンタイムを最小化します。
2. 同時に uncaughtException も設定
同期的な例外は process.on('uncaughtException') で捕捉できますが、可能な限り個別の try‑catch で事前に処理する設計が推奨されます。グローバルハンドラは最終手段として位置付けるべきです。
複数 Promise の同時実行とエラーハンドリング
複数の非同期タスクを並列に走らせる場合、失敗時の挙動を意識した API 選択が重要です。ここでは Promise.all と Promise.allSettled の使い分けを中心に解説します。
1. Promise.all の特性
| 特徴 | エラー発生時の挙動 |
|---|---|
| 即時リジェクト | どれか 1 つが reject になると全体が reject となり、残りの結果は破棄される。 |
|
1 2 3 4 5 6 7 8 9 10 |
async function fetchAll(ids) { try { const results = await Promise.all(ids.map(id => getUserProfile(id))); return results; // 全て成功した場合のみここに到達 } catch (err) { console.error('At least one request failed:', err); throw err; } } |
2. Promise.allSettled の特性
| 特徴 | エラー発生時の挙動 |
|---|---|
| 全結果取得 | 各 Promise の成功・失敗を個別に保持し、すべてのステータスが返る。 |
|
1 2 3 4 5 6 7 8 9 10 11 |
async function fetchAllGracefully(ids) { const outcomes = await Promise.allSettled(ids.map(id => getUserProfile(id))); outcomes.forEach((o, i) => { if (o.status === 'rejected') { console.warn(`User ${ids[i]} failed:`, o.reason); } }); // 成功したものだけを抽出 return outcomes.filter(o => o.status === 'fulfilled').map(o => o.value); } |
- エラーオブジェクトの統一:
reasonは通常Errorインスタンスですが、ログ解析しやすいように{name, message, code, details}の形で整形すると便利です(Node.js 公式ガイドライン[^4])。
よくある落とし穴と安全な回避策
Promise チェーンは見た目がシンプルでも、細かいミスがエラー捕捉の失敗につながります。特に then 内で 同期的に throw した場合の挙動を正しく理解しておくことが重要です。
1. then 内での throw が期待通りに働かないケース
|
1 2 3 4 5 6 7 8 |
// 誤解しやすい例 fetch('/api/item') .then(res => { if (!res.ok) throw new Error('Bad response'); // ← Promise の外側で例外が発生 return res.json(); }) .catch(err => console.error('Caught:', err)); // この catch が呼ばれないことは基本的に無い |
上記のコードは実際には catch が確実に呼び出されます。then のコールバック内でスローされた例外は内部で生成された Promise に対して reject とみなされ、直後の catch が捕捉します。「捕捉できない」ケースは次の二つです。
throwが Promise チェーンの外側で実行された(例:同期コードブロック内で直接throwした場合)。thenの戻り値を返さずに別スレッドで例外が発生した(setTimeout(() => { throw ... })等)。
安全な実装パターン
-
async/await に置き換える
js
async function getItem() {
try {
const res = await fetch('/api/item');
if (!res.ok) throw new Error('Bad response');
return await res.json();
} catch (err) {
console.error('Handled in async:', err);
throw err; // 必要なら上位へ再スロー
}
} -
Promise.resolve でラップする
js
fetch('/api/item')
.then(res => Promise.resolve().then(() => {
if (!res.ok) throw new Error('Bad response');
return res.json();
}))
.catch(err => console.error('Caught after wrap:', err)); -
ヘルパー関数で統一的にエラーハンドリング
js
function safeThen(promise, fn) {
return promise.then(v => Promise.resolve().then(() => fn(v)));
}
safeThen(fetch('/api/item'), res => {
if (!res.ok) throw new Error('Bad response');
return res.json();
}).catch(err => console.error('Handled via helper:', err));
2. catch の中で再スローし忘れる
catch 内でエラーをログだけして黙って終了すると、下流の then が成功扱いで実行されてしまうことがあります。意図的に処理を止めたい場合は必ず throw するか、代替値を返すかを明示してください。
|
1 2 3 4 5 6 7 |
promise .catch(err => { console.error('Log only:', err); throw err; // ← ここが抜けると次の then が実行されてしまう }) .then(() => {/* このブロックは実行されない */}); |
記事全体の要点まとめ
- Promise の状態 と例外が自動的に reject に変換される仕組みを正しく理解すれば、基本的なエラーハンドリングはシンプルになる。
catchはチェーン途中でも有効であり、最も近いハンドラへエラーが集約される特性を活かすとコードの可読性が向上する。- async/await + try‑catch はエラーロジックを一箇所にまとめられ、実務で推奨される設計パターンである。
- Node.js の未捕捉リジェクトは必ずハンドリングし、環境別(開発/本番)に適した対策を講じることが安全運用の鍵となる。
- 並行処理では
Promise.allとPromise.allSettledを使い分け、失敗時の挙動を意図的にコントロールする。 then内での throw は内部で reject になるが、外部でスローした例外は別途捕捉が必要。安全なラップや async/await への置き換えで落とし穴を回避できる。
これらのベストプラクティスを組み合わせれば、実務レベルで堅牢かつ保守性の高い JavaScript のエラーハンドリングが実現できます。
[^1]: MDN Web Docs, “Promise”, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
[^2]: ECMA‑262 第12版, 25.4 Promise Objects, https://tc39.es/ecma262/#sec-promise-objects
[^3]: 「Effective JavaScript」(David Herman) – Promise の使用上のベストプラクティス、章 6.5
[^4]: Node.js Documentation, “Error handling”, https://nodejs.org/api/process.html#process_event_unhandledrejection