Contents
スポンサードリンク
1. async/await の基本と try / catch パターン
ポイント
async関数内部では必ずtry / catchで例外を捕捉するのが最も直感的かつ安全。awaitは Promise が reject された瞬間に JavaScript の例外としてスローされるため、同期コードと同様の構文でエラーハンドリングできる。
背景(Reason)
| 同期コード | 非同期コード (async/await) |
|---|---|
throw → catch で捕捉 |
await が reject → try / catch で捕捉 |
Promise.reject() をそのまま返すだけでは、呼び出し側が await せずに .then/.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 |
// async 関数内での try/catch 基本形 async function top() { try { const result = await sub1(); console.log(result); } catch (e) { // すべてのエラーはここで一元管理できる console.error(`❌ top caught: ${e.message}`); } } async function sub1() { // エラーはそのまま上位へ伝搬(throw と同等) return await sub2(); } async function sub2() { const str = 'sub2str'; // 故意に TypeError を発生させる例 str.hoge.fuga = 1; // ← ここで例外がスローされ、上位の catch に届く return str; // 到達しない } top(); |
補足:Promise.reject と throw の違い
|
1 2 3 4 5 6 |
async function foo() { // ↓ どちらも呼び出し側では同等に扱えるが、内部での挙動は異なる // throw new Error('oops'); // 同期的に例外をスロー return Promise.reject(new Error('oops')); // 非同期的に reject } |
throwは即座に例外オブジェクトを生成し、awaitの直前で捕捉できる。Promise.rejectは マイクロタスク キューへ入れられるため、スタックトレースが若干ずれる点に注意。
旧環境(Node.js < 22)への代替案
| 機能 | Node.js 22+ (ESM) | Node.js < 22 |
|---|---|---|
Top‑level await |
✅ 標準実装 | ❌ --experimental-modules フラグか IIFE ((async () => { … })()) を使用 |
| ES モジュール (import) | ✅ デフォルト | CJS (require) に書き換える、または Babel/ts-node でトランスパイル |
|
1 2 3 4 5 6 7 8 9 |
// Node.js <22 でも動く IIFE パターン (async () => { try { await top(); // 上記の top() を呼び出すだけ } catch (e) { console.error(e); } })(); |
2. トップレベルでのエラーハンドリングポリシー
ポイント
- アプリ全体の例外は「トップレベル」(Express のエラーミドルウェア+プロセスレベルハンドラ)で一括管理する。
- これにより、個々のビジネスロジックで過剰な
try / catchを書く必要がなくなる。
背景(Reason)
- Express のエラーミドルウェア (
(err, req, res, next)) はリクエスト単位の例外を自動的に集約。 - Node.js 22 の top‑level await でサーバ起動時の非同期例外も捕捉可能。
process.on('unhandledRejection')とprocess.on('uncaughtException')を併用すれば、予期しない例外を ロギング+プロセス終了 という安全なフローに落とし込める。
Node.js 22 の top‑level await と代替策
| シナリオ | 推奨実装 |
|---|---|
| Node.js 22+ (ESM) | await を直接記述 |
| Node.js < 22 + ESM | node --experimental-modules でフラグ有効化(非推奨) |
| Node.js < 22 + CJS | (async () => { … })() の IIFE で包む |
| Babel/TS | トランスパイル後に await が __awaiter ヘルパーへ変換され、同等の動作になる |
実装例:Express + top‑level await(Node.js 22+)
|
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 |
// index.mjs (ESM) import express from 'express'; import { createServer } from 'http'; const app = express(); // ------------------------------------------------- // ビジネスロジックは async 関数で実装 // ------------------------------------------------- app.get('/data', async (req, res, next) => { try { const data = await fetchData(); // 任意の非同期処理 res.json(data); } catch (e) { next(e); // エラーミドルウェアへ委譲 } }); // ------------------------------------------------- // エラーハンドリングミドルウェア(全体統一) // ------------------------------------------------- app.use((err, req, res, next) => { console.error('❗️ Global error:', err); // 例: Winston + Sentry でロギング res.status(err.status ?? 500).json({ message: '内部サーバーエラー' }); }); // ------------------------------------------------- // サーバ起動を top‑level await で包む // ------------------------------------------------- const server = createServer(app); try { await new Promise((resolve, reject) => { server.listen(3000, err => (err ? reject(err) : resolve())); }); console.log('🚀 Server listening on http://localhost:3000'); } catch (e) { // 起動時の例外をここで捕捉 console.error('❌ Failed to start server:', e); process.exit(1); } // ------------------------------------------------- // プロセスレベルハンドラ(未処理例外) // ------------------------------------------------- process.on('unhandledRejection', reason => { console.warn('⚠️ Unhandled Rejection:', reason); }); process.on('uncaughtException', err => { console.error('💥 Uncaught Exception:', err); process.exit(1); // 必要に応じて graceful shutdown }); |
CJS(旧環境)での同等実装
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// index.js (CommonJS) const express = require('express'); const http = require('http'); (async () => { const app = express(); // …上記と同様のルート・ミドルウェア定義… const server = http.createServer(app); try { await new Promise((resolve, reject) => { server.listen(3000, err => (err ? reject(err) : resolve())); }); console.log('🚀 Server started'); } catch (e) { console.error(e); process.exit(1); } })(); |
まとめ
- Express のエラーミドルウェア と プロセスレベルハンドラ を組み合わせるだけで、ほとんどの例外が一元管理できる。
- Node.js 22 が使えない環境でも IIFE や Babel/TS のトランスパイル により同等のフローを実現可能。
3. fetch と async/await を組み合わせたネットワークエラー処理
ポイント
fetchは HTTP ステータスが 4xx / 5xx でも resolve するので、Response.okの判定と タイムアウト/キャンセル 用にAbortControllerを必ず組み合わせる。- Node.js 22 では
globalThis.fetchが標準実装されているが、旧環境(Node.js < 18)ではnode-fetch等のポリフィルを使用する。
背景(Reason)
| 状況 | 必要な対策 |
|---|---|
| HTTP エラー (404, 500…) | if (!response.ok) throw new Error(...) |
| ネットワーク障害・DNSエラー | catch ブロックで捕捉 |
| タイムアウト | AbortController + setTimeout |
| ユーザー操作によるキャンセル | 同上、signal.abort() を呼び出す |
実装例:タイムアウト付き fetch ラッパー
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// fetchWithTimeout.js (Node.js 22 / ブラウザ共通) export async function fetchWithTimeout(url, { timeout = 5000, ...options } = {}) { const controller = new AbortController(); const timerId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { signal: controller.signal, ...options }); if (!response.ok) { throw new Error(`HTTP ${response.status} ${response.statusText}`); } return await response.json(); // 必要に応じて text() / blob() } catch (e) { if (e.name === 'AbortError') { throw new Error('Request timed out'); } throw e; // 上位へ伝搬 } finally { clearTimeout(timerId); } } |
使用例
|
1 2 3 4 5 6 7 8 9 10 11 12 |
(async () => { try { const data = await fetchWithTimeout( 'https://api.example.com/items', { timeout: 3000, method: 'GET' } ); console.log('✅ Data received:', data); } catch (err) { console.error('❌ Fetch error:', err.message); } })(); |
AbortController を使ったリクエストキャンセル例(検索 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 |
let latestCtrl = null; function search(query) { // 前回のリクエストが残っていれば中止 if (latestCtrl) latestCtrl.abort(); const ctrl = new AbortController(); latestCtrl = ctrl; fetch(`https://api.example.com/search?q=${encodeURIComponent(query)}`, { signal: ctrl.signal, }) .then(res => { if (!res.ok) throw new Error('Search failed'); return res.json(); }) .then(result => console.log('🔍 Result:', result)) .catch(err => { if (err.name === 'AbortError') { console.warn('⏸️ Previous request aborted'); } else { console.error('❗ Search error', err); } }); } |
旧環境(Node.js < 18)向けの代替策
|
1 2 |
npm i node-fetch@2 # v2 系は CommonJS がデフォルト |
|
1 2 3 4 |
// polyfill.js const fetch = require('node-fetch'); globalThis.fetch = fetch; |
ポイント:
node-fetchはAbortControllerを標準でサポートしているが、Node.js 16 以前では実装が不完全なことがあるため、必ずバージョンを確認する。
まとめ
fetchの結果は常に resolve →Response.okで成功判定。- タイムアウト・キャンセルは AbortController が最もシンプルかつ標準的。
- Node.js 22 未使用環境では node-fetch 等のポリフィルと同様のラッパーを書けば、挙動を揃えることができる。
4. Promise.all / Promise.allSettled を使った並列処理時のエラーハンドリング
ポイント
- 全体成功が前提 →
Promise.all(失敗したら即座に例外)。 - 部分的成功でも結果を活用したい →
Promise.allSettledで個別ステータスを取得し、失敗情報だけを集約する。
背景(Reason)
| 手法 | 挙動 |
|---|---|
Promise.all |
1 つでも reject すると 全体が reject。残りの Promise は実行は続くが結果は取得できない。 |
Promise.allSettled |
全ての Promise が完了した後に {status, value/reason} の配列を返す。成功・失敗を個別に判定可能。 |
実装例:Promise.all とリトライ戦略
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
async function fetchAll(urls) { try { const results = await Promise.all( urls.map(u => fetch(u).then(r => r.json())) ); return results; } catch (e) { console.error('❗️ At least one request failed:', e); // 必要なら個別リトライロジックへ分岐 throw e; } } |
問題点
- 失敗した URL が特定できない。
- 他の成功したリクエストは結果として取得できず、無駄な再試行が発生しやすい。
Promise.allSettled で失敗情報を集約
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
async function fetchAllSafe(urls) { const settled = await Promise.allSettled( urls.map(u => fetch(u).then(r => r.json())) ); const successes = []; const failures = []; settled.forEach((result, i) => { if (result.status === 'fulfilled') { successes.push(result.value); } else { failures.push({ url: urls[i], reason: result.reason }); } }); if (failures.length) { console.warn('⚠️ Some requests failed:', failures); // Sentry, Datadog 等へ送信することも可 } return successes; } |
補足:同時実行数の上限制御(p-limit)
大量 URL を一括で Promise.all* に渡すと ネットワーク過負荷 が起きやすい。p-limit パッケージで同時実行数を絞る例:
|
1 2 |
npm i p-limit |
|
1 2 3 4 5 6 7 8 |
import pLimit from 'p-limit'; async function fetchWithConcurrency(urls, concurrency = 5) { const limit = pLimit(concurrency); const tasks = urls.map(u => limit(() => fetch(u).then(r => r.json()))); return await Promise.allSettled(tasks); } |
まとめ
- 全体成功が必須 →
Promise.all+ 必要に応じてリトライ。 - 部分成功でも有用なデータがある →
Promise.allSettledで失敗情報を集約し、ロギングや再試行の判断材料にする。 - 同時実行数が多い場合は
p-limit等で制御 するとリソース消費を抑えられる。
5. カスタム Error クラス・型安全な catch と実務向け連携
ポイント
- ビジネスドメインごとに 専用のエラークラス(例:
ValidationError,NotFoundError)を作ることで、ハンドラ側で「何が起きたか」を明示的に判定できる。 - TypeScript では 型ガード を併せて実装すれば、
catch内でプロパティへ安全にアクセス可能。
背景(Reason)
標準 Error |
カスタムエラー |
|---|---|
| メッセージだけ → 判別が困難 | 名前・固有プロパティ → ロギングや UI へのフィードバックが容易 |
| スタックトレースは同じ | エラーレベル(致命的/警告)をクラスで分離できる |
実装例:TypeScript のカスタムエラーと型ガード
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// errors.ts export class ValidationError extends Error { constructor(public readonly field: string, message: string) { super(message); this.name = 'ValidationError'; } } export class NotFoundError extends Error { constructor(public readonly resourceId: string) { super(`Resource ${resourceId} not found`); this.name = 'NotFoundError'; } } // 型ガード export function isValidationError(err: unknown): err is ValidationError { return err instanceof ValidationError; } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// service.ts import { ValidationError, NotFoundError, isValidationError } from './errors'; export async function getUser(id: string) { if (!/^\d+$/.test(id)) { throw new ValidationError('id', 'ID must be numeric'); } const user = await db.findUser(id); if (!user) { throw new NotFoundError(id); } return user; } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// 呼び出し側 (例) (async () => { try { await getUser('abc'); // 故意に不正 ID } catch (e) { if (isValidationError(e)) { console.warn(`🔧 Validation error on ${e.field}: ${e.message}`); } else if (e instanceof NotFoundError) { console.error(`❌ ${e.message}`); } else { console.error('💥 Unexpected error:', e); } } })(); |
Winston + Sentry によるロギング・モニタリング(Node.js)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// logger.js import winston from 'winston'; import * as Sentry from '@sentry/node'; Sentry.init({ dsn: process.env.SENTRY_DSN }); export const logger = winston.createLogger({ level: 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.json() ), transports: [new winston.transports.Console()], }); // Express 用エラーハンドラ例 export function errorHandler(err, req, res, next) { logger.error('Unhandled error', { message: err.message, stack: err.stack }); Sentry.captureException(err); const status = err.status ?? 500; res.status(status).json({ error: err.message }); } |
純粋 JavaScript(Node.js < 22)でも利用可能
|
1 2 3 4 5 6 |
// logger.cjs (CommonJS) const winston = require('winston'); module.exports = winston.createLogger({ // 同上… }); |
Jest での async/await エラーハンドリングテスト例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// userService.test.ts import { getUser } from './service'; import { ValidationError, NotFoundError } from './errors'; import db from './db'; // モック対象 test('throws ValidationError for non‑numeric id', async () => { await expect(getUser('abc')).rejects.toThrow(ValidationError); }); test('throws NotFoundError when user does not exist', async () => { jest.spyOn(db, 'findUser').mockResolvedValueOnce(null); await expect(getUser('123')).rejects.toMatchObject({ name: 'NotFoundError', resourceId: '123', }); }); |
expect(...).rejectsにより Promise が reject したことを簡潔に検証。- カスタムエラーのプロパティまでアサートできるので、ビジネスロジックの正確性が保証される。
まとめ
- カスタムエラー + 型ガード → ハンドラで「何が失敗したか」=> ロギング・ユーザー通知が容易。
- Winston + Sentry による一元ロギングは、Node.js 22 だけでなく旧バージョンでも同様に設定可能(CJS 版も用意)。
- Jest の
rejectsアサーション は async/await のエラーハンドリングをテストする最短手段。
6. 総合まとめ
| 項目 | 推奨実装ポイント |
|---|---|
| 基本的な例外捕捉 | async 内は必ず try / catch。await が reject 時に自動で例外化される点を活かす。 |
| トップレベルハンドリング | Express のエラーミドルウェア + プロセスレベル (unhandledRejection, uncaughtException)。Node.js 22 では top‑level await、旧環境は IIFE または Babel/TS で代替。 |
| ネットワーク通信 | fetch はステータスチェック必須。AbortController によるタイムアウト/キャンセルを標準実装し、Node.js < 18 は node-fetch ポリフィルで同等化。 |
| 並列処理 | 全体成功が必要 → Promise.all(失敗は即例外)。部分成功でも結果活用 → Promise.allSettled+失敗情報集約。大量タスクは p-limit 等で同時実行数を制御。 |
| カスタムエラー | ビジネスドメインごとにクラス化し、型ガードで安全に判定。ロギングは Winston + Sentry、テストは Jest の rejects を使用。 |
| 互換性対策 | Node.js 22 未使用時は IIFE, --experimental-modules, Babel/TS で top‑level await 相当を実装し、ESM と CJS の両方に対応できるようコードを分岐させる。 |
以上のベストプラクティスをプロジェクトに組み込むことで、非同期処理全体の例外が統一的に管理され、保守性・信頼性が飛躍的に向上します。コードレビュー時のチェックリストに「async/await → try/catch」「トップレベルエラーハンドラ有無」などを追加し、チーム全体で安全な非同期プログラミング文化を醸成してください。
スポンサードリンク