JavaScriptのエラーハンドリング入門|try・catch・finallyからカスタムエラー・Promiseのエラー処理まで


はじめに

前回の記事ではJavaScriptのDOM操作として要素の取得・作成・追加・削除からテンプレートリテラルを使った動的なHTML生成まで解説しました。今回はJavaScriptのエラーハンドリングを解説します。「エラーが発生してページが壊れた」「どこで何が起きているかわからない」「APIの通信失敗をユーザーにうまく伝えられない」という悩みをエラーハンドリングで解決できます。エラーを適切に処理することで、予期しない問題が起きてもアプリケーションが安全に動き続けるようになります。


1. エラーとは何か

1.1 JavaScriptのエラーの種類

JavaScriptのエラーは大きく2種類:

① 構文エラー(SyntaxError):
   → コードの書き方が間違っている
   → ブラウザがコードを実行する前に検出される
   → 例:括弧や波カッコの閉じ忘れ

② 実行時エラー(Runtime Error):
   → コードの実行中に発生する
   → try/catch で捕捉できる

よく遭遇するエラーの種類:

ReferenceError:
→ 存在しない変数を参照した
→ 例:console.log( undeclaredVariable )

TypeError:
→ 値の型が間違っている
→ 例:null.textContent = 'hello'
→ 例:undefinedValue()

RangeError:
→ 値が許容範囲を超えている
→ 例:new Array( -1 )

URIError:
→ URIの処理で問題が発生した
→ 例:decodeURIComponent( '%' )

NetworkError(fetchなど):
→ ネットワーク通信に失敗した
→ 例:存在しないURLへのfetch

SyntaxError(JSON.parse):
→ 不正なJSONをパースした
→ 例:JSON.parse( 'invalid json' )

2. try・catch・finally の基本

2.1 try・catch の基本構文

/*
  try:
  → エラーが発生するかもしれないコードを書く

  catch:
  → エラーが発生したときの処理を書く
  → エラーオブジェクトが引数として渡される

  finally:
  → エラーの有無に関わらず必ず実行される
  → クリーンアップ処理に使う
*/

try {
    // エラーが発生するかもしれない処理
    const data = JSON.parse( '{ invalid json }' );
    console.log( data );
} catch ( error ) {
    // エラーが発生したときの処理
    console.error( 'エラーが発生しました:', error.message );
} finally {
    // 必ず実行される処理
    console.log( '処理が完了しました' );
}

2.2 エラーオブジェクトのプロパティ

try {
    null.textContent = 'hello'; // TypeError が発生
} catch ( error ) {
    console.log( error.name );     // 'TypeError'
    console.log( error.message );  // "Cannot set properties of null"
    console.log( error.stack );    // スタックトレース(どこで発生したか)

    // エラーの種類によって処理を分ける
    if ( error instanceof TypeError ) {
        console.log( 'TypeError が発生しました' );
    } else if ( error instanceof ReferenceError ) {
        console.log( 'ReferenceError が発生しました' );
    } else {
        console.log( '予期しないエラー:', error );
    }
}

2.3 throw でエラーを発生させる

/*
  throw:
  意図的にエラーを発生させる。
  バリデーションや前提条件のチェックに使う。
*/

function divide( a, b ) {
    if ( typeof a !== 'number' || typeof b !== 'number' ) {
        throw new TypeError( '引数は数値である必要があります' );
    }
    if ( b === 0 ) {
        throw new RangeError( '0で割ることはできません' );
    }
    return a / b;
}

try {
    console.log( divide( 10, 2 ) );   // 5
    console.log( divide( 10, 0 ) );   // RangeError が発生
} catch ( error ) {
    if ( error instanceof RangeError ) {
        console.error( '計算エラー:', error.message );
        // → 計算エラー: 0で割ることはできません
    } else {
        console.error( '予期しないエラー:', error );
    }
}

2.4 finally の使い道

// ローディング状態の管理(finallyで確実に解除する)
function showLoading() {
    document.querySelector( '.loading' ).classList.add( 'is-visible' );
}

function hideLoading() {
    document.querySelector( '.loading' ).classList.remove( 'is-visible' );
}

async function fetchData( url ) {
    showLoading();

    try {
        const response = await fetch( url );
        const data     = await response.json();
        return data;
    } catch ( error ) {
        console.error( 'データの取得に失敗:', error );
        throw error; // 呼び出し元に再スローする
    } finally {
        hideLoading(); // 成功・失敗に関わらず必ず実行
        // → try内でreturnしても・catch内でthrowしても実行される
    }
}

3. カスタムエラーを作る

3.1 Errorクラスを継承する

/*
  カスタムエラー:
  Errorクラスを継承して独自のエラー型を作る。

  メリット:
  → instanceof で種類を判別できる
  → エラーに追加情報を持たせられる
  → エラーの意味が明確になる
*/

// HTTPエラー
class HttpError extends Error {
    constructor( status, message ) {
        super( message );
        this.name   = 'HttpError';
        this.status = status;
    }
}

// バリデーションエラー
class ValidationError extends Error {
    constructor( field, message ) {
        super( message );
        this.name  = 'ValidationError';
        this.field = field;
    }
}

// ネットワークエラー
class NetworkError extends Error {
    constructor( message = 'ネットワークに接続できません' ) {
        super( message );
        this.name = 'NetworkError';
    }
}

// 使用例
async function fetchUser( id ) {
    if ( !id ) {
        throw new ValidationError( 'id', 'ユーザーIDは必須です' );
    }

    let response;

    try {
        response = await fetch( `/api/users/${ id }` );
    } catch {
        throw new NetworkError();
    }

    if ( !response.ok ) {
        throw new HttpError(
            response.status,
            `ユーザーの取得に失敗しました(${ response.status })`
        );
    }

    return response.json();
}

// 呼び出し側でエラーを種類別に処理する
async function loadUser( id ) {
    try {
        const user = await fetchUser( id );
        renderUser( user );
    } catch ( error ) {
        if ( error instanceof ValidationError ) {
            showFieldError( error.field, error.message );
        } else if ( error instanceof HttpError ) {
            if ( error.status === 404 ) {
                showMessage( 'ユーザーが見つかりませんでした' );
            } else if ( error.status === 401 ) {
                redirectToLogin();
            } else {
                showMessage( 'サーバーエラーが発生しました' );
            }
        } else if ( error instanceof NetworkError ) {
            showMessage( 'インターネット接続を確認してください' );
        } else {
            console.error( '予期しないエラー:', error );
            showMessage( '予期しないエラーが発生しました' );
        }
    }
}

4. Promiseのエラーハンドリング

4.1 async/await と try/catch

// async/await では try/catch でエラーを捕捉する

async function getPost( id ) {
    try {
        const response = await fetch( `/api/posts/${ id }` );

        // fetchはネットワークエラー以外でthrowしないため
        // レスポンスのステータスコードを必ず確認する
        if ( !response.ok ) {
            throw new HttpError(
                response.status,
                `HTTPエラー: ${ response.status }`
            );
        }

        const post = await response.json();
        return post;

    } catch ( error ) {
        // エラーをそのまま再スローして呼び出し元で処理する
        throw error;
    }
}

// ============================================

// 複数のAPIを並列で取得する場合
async function getDashboardData() {
    try {
        // Promise.all:すべて成功したら結果を返す
        // 1つでも失敗したらcatchに入る
        const [ user, posts, notifications ] = await Promise.all( [
            fetch( '/api/user' ).then( ( r ) => r.json() ),
            fetch( '/api/posts' ).then( ( r ) => r.json() ),
            fetch( '/api/notifications' ).then( ( r ) => r.json() ),
        ] );

        return { user, posts, notifications };

    } catch ( error ) {
        console.error( 'ダッシュボードデータの取得に失敗:', error );
        throw error;
    }
}

// ============================================

// Promise.allSettled:すべての結果を待って
// 成功・失敗それぞれ処理する
async function getDataWithFallback() {
    const results = await Promise.allSettled( [
        fetch( '/api/main-data' ).then( ( r ) => r.json() ),
        fetch( '/api/optional-data' ).then( ( r ) => r.json() ),
    ] );

    const [ mainResult, optionalResult ] = results;

    if ( mainResult.status === 'rejected' ) {
        throw new Error( 'メインデータの取得に失敗しました' );
    }

    return {
        main:     mainResult.value,
        optional: optionalResult.status === 'fulfilled'
                    ? optionalResult.value
                    : null, // オプションデータは失敗してもnullで続行
    };
}

4.2 グローバルなエラーハンドリング

/*
  キャッチされなかったエラーを
  グローバルで処理する仕組み。

  開発中のデバッグ・本番でのエラーロギングに使う。
*/

// 同期処理のキャッチされなかったエラー
window.addEventListener( 'error', ( event ) => {
    console.error( 'キャッチされなかったエラー:', {
        message:  event.message,
        filename: event.filename,
        lineno:   event.lineno,
        colno:    event.colno,
        error:    event.error,
    } );

    // エラーログをサーバーに送信する
    sendErrorLog( {
        type:    'uncaught_error',
        message: event.message,
        stack:   event.error?.stack,
    } );
} );

// Promiseのキャッチされなかったエラー
window.addEventListener( 'unhandledrejection', ( event ) => {
    console.error( 'キャッチされなかったPromiseエラー:', event.reason );

    // エラーログをサーバーに送信する
    sendErrorLog( {
        type:   'unhandled_rejection',
        reason: String( event.reason ),
    } );

    // デフォルトの動作(コンソールへの警告)を抑制する
    event.preventDefault();
} );

// エラーログを送信するユーティリティ
async function sendErrorLog( data ) {
    try {
        await fetch( '/api/error-log', {
            method:  'POST',
            headers: { 'Content-Type': 'application/json' },
            body:    JSON.stringify( {
                ...data,
                url:       window.location.href,
                userAgent: navigator.userAgent,
                timestamp: new Date().toISOString(),
            } ),
        } );
    } catch {
        // ログ送信自体が失敗してもアプリを止めない
    }
}

5. ユーザーへのエラー表示パターン

5.1 トースト通知

class Toast {
    #container;
    #defaultDuration;

    constructor( duration = 4000 ) {
        this.#defaultDuration = duration;
        this.#createContainer();
    }

    #createContainer() {
        this.#container = document.createElement( 'div' );
        this.#container.className   = 'toast-container';
        this.#container.setAttribute( 'aria-live', 'polite' ); // スクリーンリーダー対応
        document.body.appendChild( this.#container );
    }

    #show( message, type, duration ) {
        const toast = document.createElement( 'div' );
        toast.className   = `toast toast--${ type }`;
        toast.textContent = message;
        toast.setAttribute( 'role', 'alert' );

        this.#container.appendChild( toast );

        // アニメーション後に削除する
        setTimeout( () => {
            toast.classList.add( 'toast--hiding' );
            toast.addEventListener( 'animationend', () => toast.remove() );
        }, duration ?? this.#defaultDuration );

        return toast;
    }

    success( message, duration ) {
        return this.#show( message, 'success', duration );
    }

    error( message, duration ) {
        return this.#show( message, 'error', duration ?? 6000 );
    }

    warning( message, duration ) {
        return this.#show( message, 'warning', duration );
    }

    info( message, duration ) {
        return this.#show( message, 'info', duration );
    }
}

// シングルトンとして使う
const toast = new Toast();

// 使用例
toast.success( 'フォームを送信しました!' );
toast.error( 'エラーが発生しました。もう一度お試しください。' );
toast.warning( '入力内容に問題があります' );
toast.info( '自動保存しました' );

5.2 フォームのエラー表示

class FormErrorHandler {
    constructor( formSelector ) {
        this.form = document.querySelector( formSelector );
    }

    // フィールドのエラーを表示する
    showFieldError( fieldName, message ) {
        const field   = this.form.querySelector( `[name="${ fieldName }"]` );
        const errorEl = this.form.querySelector( `#${ fieldName }-error` );

        if ( field ) {
            field.classList.add( 'is-error' );
            field.setAttribute( 'aria-invalid', 'true' );
            field.setAttribute( 'aria-describedby', `${ fieldName }-error` );
        }

        if ( errorEl ) {
            errorEl.textContent = message;
            errorEl.removeAttribute( 'hidden' );
        }
    }

    // フィールドのエラーをクリアする
    clearFieldError( fieldName ) {
        const field   = this.form.querySelector( `[name="${ fieldName }"]` );
        const errorEl = this.form.querySelector( `#${ fieldName }-error` );

        if ( field ) {
            field.classList.remove( 'is-error' );
            field.removeAttribute( 'aria-invalid' );
        }

        if ( errorEl ) {
            errorEl.textContent = '';
            errorEl.setAttribute( 'hidden', '' );
        }
    }

    // フォーム全体のエラーを表示する
    showFormError( message ) {
        let alertEl = this.form.querySelector( '.form-alert' );

        if ( !alertEl ) {
            alertEl = document.createElement( 'div' );
            alertEl.className = 'form-alert form-alert--error';
            alertEl.setAttribute( 'role', 'alert' );
            this.form.prepend( alertEl );
        }

        alertEl.textContent = message;
        alertEl.removeAttribute( 'hidden' );

        // フォームアラートにフォーカスを移す
        alertEl.setAttribute( 'tabindex', '-1' );
        alertEl.focus();
    }

    // すべてのエラーをクリアする
    clearAll() {
        this.form.querySelectorAll( '.is-error' ).forEach( ( el ) => {
            el.classList.remove( 'is-error' );
            el.removeAttribute( 'aria-invalid' );
        } );

        this.form.querySelectorAll( '[id$="-error"]' ).forEach( ( el ) => {
            el.textContent = '';
            el.setAttribute( 'hidden', '' );
        } );

        this.form.querySelector( '.form-alert' )?.setAttribute( 'hidden', '' );
    }
}

5.3 エラー境界(エラー状態のUI)

class ErrorBoundary {
    constructor( containerSelector ) {
        this.container = document.querySelector( containerSelector );
        this.#saveOriginalContent();
    }

    #originalContent = '';

    #saveOriginalContent() {
        this.#originalContent = this.container.innerHTML;
    }

    // エラー表示に切り替える
    showError( {
        title   = 'エラーが発生しました',
        message = '予期しないエラーが発生しました。',
        retry   = null,
    } = {} ) {
        this.container.innerHTML = `
            <div class="error-state" role="alert">
                <div class="error-icon" aria-hidden="true">⚠️</div>
                <h2 class="error-title">${ title }</h2>
                <p class="error-message">${ message }</p>
                ${ retry ? `
                    <button class="btn btn-retry">
                        もう一度試す
                    </button>
                ` : '' }
            </div>
        `;

        if ( retry ) {
            this.container.querySelector( '.btn-retry' )
                ?.addEventListener( 'click', retry );
        }
    }

    // 空の状態を表示する
    showEmpty( message = 'データが見つかりませんでした' ) {
        this.container.innerHTML = `
            <div class="empty-state">
                <div class="empty-icon" aria-hidden="true">📭</div>
                <p class="empty-message">${ message }</p>
            </div>
        `;
    }

    // ローディング表示に切り替える
    showLoading( message = '読み込み中...' ) {
        this.container.innerHTML = `
            <div class="loading-state" aria-busy="true" aria-label="${ message }">
                <div class="spinner" aria-hidden="true"></div>
                <p>${ message }</p>
            </div>
        `;
    }

    // 元のコンテンツに戻す
    restore() {
        this.container.innerHTML = this.#originalContent;
    }
}

// 実践的な使用例
class PostLoader {
    #boundary;

    constructor( containerSelector ) {
        this.#boundary = new ErrorBoundary( containerSelector );
    }

    async load( postId ) {
        this.#boundary.showLoading( '記事を読み込んでいます...' );

        try {
            const response = await fetch( `/api/posts/${ postId }` );

            if ( !response.ok ) {
                if ( response.status === 404 ) {
                    this.#boundary.showEmpty( '記事が見つかりませんでした' );
                    return;
                }
                throw new HttpError( response.status, 'サーバーエラー' );
            }

            const post = await response.json();

            // データがない場合
            if ( !post ) {
                this.#boundary.showEmpty();
                return;
            }

            this.#renderPost( post );

        } catch ( error ) {
            this.#boundary.showError( {
                title:   '記事の読み込みに失敗しました',
                message: error instanceof NetworkError
                    ? 'インターネット接続を確認してください'
                    : 'しばらくしてからもう一度お試しください',
                retry:   () => this.load( postId ),
            } );
        }
    }

    #renderPost( post ) {
        // 記事を描画する処理
    }
}

6. エラーハンドリングのベストプラクティス

6.1 エラーを握りつぶさない

// ❌ エラーを握りつぶすのはNG
try {
    riskyOperation();
} catch ( error ) {
    // 何もしない → エラーが発生したことがわからなくなる
}

// ❌ console.logだけでは不十分
try {
    riskyOperation();
} catch ( error ) {
    console.log( error ); // ログだけ残してそのまま続行
}

// ✅ エラーを適切に処理する
try {
    riskyOperation();
} catch ( error ) {
    // ① ログに記録する
    console.error( '操作が失敗しました:', error );

    // ② ユーザーに通知する
    toast.error( 'エラーが発生しました。もう一度お試しください。' );

    // ③ 必要なら再スローする
    throw error;

    // ④ またはフォールバックの処理をする
    return defaultValue;
}

6.2 エラーメッセージを使い分ける

/*
  ユーザーへのメッセージと
  開発者向けのログは分ける。

  ユーザーには:
  → わかりやすい日本語
  → 次にどうすればよいかを伝える
  → 技術的な詳細は不要

  開発者には:
  → エラーの種類・スタックトレース
  → どのAPIが失敗したか
  → リクエストのパラメータなど
*/

async function submitForm( data ) {
    try {
        const response = await fetch( '/api/contact', {
            method:  'POST',
            headers: { 'Content-Type': 'application/json' },
            body:    JSON.stringify( data ),
        } );

        if ( !response.ok ) {
            const errorData = await response.json().catch( () => ({}) );

            // 開発者向けログ(詳細)
            console.error( 'APIエラー:', {
                status:  response.status,
                url:     response.url,
                data:    errorData,
                payload: data,
            } );

            // ユーザー向けメッセージ(シンプル)
            const userMessage = response.status === 422
                ? '入力内容に問題があります。確認してもう一度お試しください。'
                : 'エラーが発生しました。しばらくしてからもう一度お試しください。';

            throw new Error( userMessage );
        }

        toast.success( 'お問い合わせを送信しました!' );

    } catch ( error ) {
        toast.error( error.message );
    }
}

6.3 リトライ処理

// 一時的なエラーに対してリトライする
async function fetchWithRetry( url, options = {}, maxRetries = 3 ) {
    let lastError;

    for ( let attempt = 1; attempt <= maxRetries; attempt++ ) {
        try {
            const response = await fetch( url, options );

            if ( !response.ok ) {
                // 5xx エラーはリトライする・4xx はリトライしない
                if ( response.status >= 500 ) {
                    throw new HttpError( response.status, 'サーバーエラー' );
                }
                return response; // 4xx はそのまま返す
            }

            return response;

        } catch ( error ) {
            lastError = error;
            console.warn( `試行 ${ attempt }/${ maxRetries } 失敗:`, error.message );

            // 最後の試行ならリトライしない
            if ( attempt === maxRetries ) break;

            // 指数バックオフ:待ち時間を指数的に増やす
            const waitMs = Math.min( 1000 * 2 ** ( attempt - 1 ), 10000 );
            // → 1秒 → 2秒 → 4秒 → 最大10秒
            await new Promise( ( resolve ) => setTimeout( resolve, waitMs ) );
        }
    }

    throw lastError;
}

// 使用例
try {
    const response = await fetchWithRetry( '/api/data', {}, 3 );
    const data     = await response.json();
    renderData( data );
} catch ( error ) {
    console.error( '3回リトライしましたが失敗しました:', error );
    showError( 'データの取得に失敗しました' );
}

7. 実践チェックリスト

✓ fetch のレスポンスで response.ok を確認して HTTPエラーを処理しているか

✓ async/await を使うときに try/catch でエラーを捕捉しているか

✓ finally を使ってローディング状態のクリーンアップを確実に行っているか

✓ カスタムエラークラスで instanceof によるエラー種別の判別ができているか

✓ ユーザーへのエラーメッセージと開発者向けログを分けて管理しているか

✓ window.addEventListener('unhandledrejection') でキャッチされなかったPromiseエラーを補足しているか

✓ エラーを握りつぶさず・ログに記録してユーザーに通知しているか

✓ ネットワークエラーなど一時的な障害に対してリトライ処理を実装しているか


まとめ

今回はJavaScriptのエラーハンドリングをtry・catch・finallyの基本からカスタムエラー・Promiseのエラー処理・ユーザーへの通知パターンまで解説しました。ポイントをまとめると:

  • try/catch でエラーを捕捉し・error.name / error.message / error.stack でエラーの内容を把握する
  • finally はエラーの有無に関わらず必ず実行されるのでローディング状態の解除など確実に行いたい処理に使う
  • throw で意図的にエラーを発生させてバリデーションや前提条件のチェックをする
  • Error を継承したカスタムエラークラスを作ると instanceof で種類を判別してエラーごとに適切な処理ができる
  • fetch はネットワークエラー以外は throw しないため response.ok を必ず確認してHTTPエラーを自分で処理する
  • Promise.allSettled を使うとすべての結果を待ちながら成功・失敗それぞれを個別に処理できる
  • window.addEventListener('unhandledrejection') でキャッチされなかったPromiseエラーをグローバルで処理できる
  • エラーを握りつぶさず・ユーザーには平易なメッセージを・開発者向けには詳細なログを残す習慣をつける

次の記事では、JavaScriptのモジュールシステムを解説します。importとexportの使い方でコードをファイルに分割して整理する方法をお届けします。お楽しみに!

コメント

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