iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

エラーハンドリングを書きたいあなたへ

はじめに

株式会社iimonでフロントエンドを担当している齋藤です

業務でエラーハンドリングをがっつりやることがあったため、まとめてみました!

エラーハンドリングとは

エラーハンドリングとは、プログラムやシステムが動作中に発生する予期しないエラーや例外(想定外の事態)に対して適切な処理を行う仕組みや方法のことです。

プログラムが予期しない動作をしたりクラッシュ(強制終了)したりすることがあります。しかしエラーハンドリングをすることによって、予期しないクラッシュやプログラムの停止を避けることができます。

予期しないエラーが発生して強制終了したコード例

const message: string = "Hello, TypeScript!";
const heading = document.createElement('h1');
heading.className = 'true-str';
heading.textContent = message;
document.body.appendChild(heading);

const str = document.querySelector('.false-str')!;
console.log(str.textContent);
console.log("その後の処理");

TypeErrorによる強制終了

強制終了させるためにわざと!(非 null アサーション演算子)を使用しています

エラーを発生させないようにするにはオプショナルチェーンをつかうという手もあります

エラーハンドリングの方式

プログラムにおけるエラーハンドリングには、戻り値方式と例外方式があります。

戻り値方式は戻り値リターンでエラーを伝え、モジュールでエラー対処します。

C、Go、Rustなどで使用されています。

それに対して例外方式は例外スローでエラーを伝え、例外キャッチしたモジュールでエラー対処します。こちらはC++JavaPythonRubyPHPJavaScriptなど様々な言語で使用されており、例外方式の方が主流になっています。

今回は例外方式について、詳しく見ていきたいと思います。

エラーハンドリングの基本的なやり方

例外処理が発生した際に、プログラムを強制終了しないようにするにはtry~catch 文を使用することができます。

これはtry ブロックの中のプログラムを実行して、tryブロックの中で例外が発生した場合はcatch プログラムを実行するというものです。catch ブロックの実行の際はthrow 文によって投げられたError オブジェクトがcatch で宣言された変数に入ります。(err)

つまり、try ブロックの中で例外が発生しても、その例外を捕捉することでプログラムの強制終了を回避することができます。

try {
    //tryブロックの処理
} catch(err){
    //catchブロックの処理
}

下のようにcatch で宣言した変数(err)をcatch ブロック内で使用しないこともできます。

ですが、キャッチされたエラーを見なかったり、エラー処理を何もしないで次に進んだりするのは悪い例外処理の典型例として知られています。

try {
    //tryブロックの処理
} catch(err){
    //何もしない
}

先程のコードに当てはめてみます。

try{
    const message: string = "Hello, TypeScript!";
    const heading = document.createElement('h1');
    heading.className = 'true-str';
    heading.textContent = message;
    document.body.appendChild(heading);
    
    const str = document.querySelector('.false-str')!;
    console.log(str.textContent);
} catch(e) {
    //何もしないよ
}
console.log("例外が起こったのに何もしてないよ");

結果

先ほどのtry~catchじゃない時はconsole.log(str.textContent); の箇所で、プログラムが

強制終了していましたが、強制終了せず今回は何事もなかったようにtry~catch の次の処理に移っています。

実際のところ、エラーが発生したら対応としてはやるべきことはたくさんあり、最低でもエラーメッセージをログに残すなどはやっておいたほうがよいでしょう。

エラーハンドリングの動き(大域脱出)

その場で実行を中断して別の場所にプログラムの制御を移すことを大域脱出といいます。

例外は大域脱出の特徴をもちます。

エラー(例外)をthrow で発生させたら、プログラムがthrow の次の文に進むことは決してなく、

エラーが発生したら必ずプログラムは中断し、エラーはプログラムの外側へと脱出を始めます。

今いる場所が、ブロックの中ならブロックの外、関数ならその関数の外へと動きます。

脱出の際にtry~catch 文のtry ブロックに行き当たった場合はそこでエラーがキャッチされて、そこからまたプログラムが再開されます。

脱出の際にtry ブロックに行き当たったコード例

try {
    try {
        const message: string = "Hello, TypeScript!";
        const heading = document.createElement('h1');
        heading.className = 'true-str';
        heading.textContent = message;
        document.body.appendChild(heading);

        const str = document.querySelector('.false-str')!;
        console.log(str.textContent);
    } catch (e) {
        console.error('1回目のエラーが発生しました:', e);
        throw new Error('1回目のエラーが再スローされました');
        console.log("ここは実行されないよ")
    }
} catch (e) {
    console.error('外側のcatchブロックでキャッチ:');
}

結果

処理の動きとしては、例のごとくconsole.log(str.textContent);catch の処理に入り エラーをthrow しています。その後、エラーは大域脱出をして1回目のtry ブロックに入り、そこでエラーがキャッチされ、 console.error('外側のcatchブロックでキャッチ:')が実行されるという流れになります。つまりネストしたtry~catch の場合、例外が発生した最も内側のcatch ブロックで1度だけキャッチされるということになります。

try ブロックに行き当たった場合、エラーがキャッチされると書きましたが、以下のコードの場合、1回目のcatchブロックでthrowしたエラーは2回目のtry~catchではキャッチされません。

これはエラーがキャッチされずにプログラムの一番外側まで到達した場合はプログラムは強制終了することになっているためです。

つまり1回目のcatch でエラーがthrow され、エラーは1回目のtryブロックの外へと大域脱出して、プログラムの一番外側まで到達しているので、プログラムが強制終了しています

try {
    const message: string = "Hello, TypeScript!";
    const heading = document.createElement('h1');
    heading.className = 'true-str';
    heading.textContent = message;
    document.body.appendChild(heading);

    const str = document.querySelector('.false-str')!;
    console.log(str.textContent); 
} catch (e) {
    console.error('1回目のエラーが発生しました:', e);
    throw new Error('1回目のエラーが再スローされました');
}

try {
    console.log("2回目のtryブロック");
} catch (e) {
    console.error('2回目のエラーキャッチ:');
}

結果

try~catchのメリット

try~catch で文でプログラムを囲んでおけば、その中で発生した例外はすべてそのtry~catch 文で処理でき、いろいろな部分で例外が発生する可能性がある場合、その処理をまとめられるのは大きなメリットの1つです。下のコードは複数の箇所でエラーが発生する可能性ありますが、どこでエラーが発生したとしても、途中で中断して1箇所に制御を集めることができます。

複数の例外が発生しそうなコード例

try {
    // 例外が発生し得る処理1: 配列の要素に無効なインデックスでアクセス
    const array = [1, 2, 3];
    console.log(array[10]); // undefined だが例外は発生しない
    
    // 例外が発生し得る処理2: 存在しない関数の呼び出し (エラーが発生)
    nonExistentFunction();
    
    // 例外が発生し得る処理3: ゼロで割り算
    const num1 = 10;
    const num2 = 0;
    const result = num1 / num2; // Infinity が出力されるがエラーではない
    console.log("計算結果:", result);
    
    // 例外が発生し得る処理4: 存在しない要素へのアクセス
    const element = document.querySelector('.non-existent-class');
    console.log(element.textContent); // エラーが発生する
}
catch (error) {
    // ここで try ブロックのどこかで発生した例外をキャッチし、一括して処理する
    console.error("例外が発生しました:", error.message);
}
// try-catchの外でもプログラムは継続する
console.log("プログラムが継続しています。");

typescriptではコンパイルエラーになるため、javascript でのコード

結果

実際にはnonExistentFunction(); でおきた例外をcatch しています。そのあとのコードは実行されません。

逆にエラーによって処理が違う場合はあまりtry~catch は適していないのかもしれません。

ちなみにtry~catch でも以下のようにすることでエラーの処理を分けることもできます。

try {
    const message: string = "Hello, TypeScript!";
    const heading = document.createElement('h1');
    heading.className = 'true-str';
    heading.textContent = message;
    document.body.appendChild(heading);

    const str = document.querySelector('.false-str')!;
    console.log(str.textContent); 
} catch (e) {
    if (e instanceof Error) {
        if (e.name === "TypeError") { 
            console.log("TypeErrorのときの処理:", e.message);
        }
    } else {
        console.log("予期しないエラー:", e);
        throw new Error('予期しないエラーを再スロー');
    }
}

結果

これは、console.log(str.textContent); でtypeエラーになるので if (e.name === "TypeError") の処理に入っています。

ここで注目してほしいのは、if (e instanceof Error) です。

TypeScript では e の型は unknown として扱うため、プロパティにアクセスしようとするとエラーが発生します。

そこでinstanceof をつかうことでエラーが実際に Errorインスタンスであることが保証され、プロパティにアクセスすることができます。

エラーの種類だけで分けるだけなら以下でできます。

catch (e) {
    if (e instanceof TypeError) {
        console.log("TypeErrorのときの処理:", e.message);
    } else {
        console.log("予期しないエラー:", e);
        throw new Error('予期しないエラーを再スロー');
    }
}

エラーの処理を分けるにはこのように処理の分岐をするより、返り値を使ってエラーの制御をしたほうがきれいにプログラムをかける可能性が大きくなります

非同期処理をtry~catchでエラーハンドリングしようとすると、、、

下記のコードはsetTimeout 関数のコールバックが1秒後に実行されてエラーをthrow しています。

しかしそのときには、try~catch の実行が終了したあとなので、例外をcatch することができずにプログラムが終了してしまいます。

try {
  setTimeout(() => {
    throw new Error('非同期的なエラー');
  }, 1000);
} catch (error) {
  console.error('catchブロック');
}
console.log('この行は実行');

結果

この非同期処理のエラー処理を解決するために導入されたのが、promiseasync/await になります。

promiseでのエラーハンドリング

まずはpromise をつかったコードでエラーハンドリングが必要な例を見てみましょう。

以下のコードは、50%の確率で成功するコードです。

成功しない場合は例外をthrowするようにします。

ですが、このコードでは失敗したpromise に何もコールバック関数が登録されておらず、プログラムが強制終了してしまいます。

ちなみにPromiseにおいてthrow 文の使用はエラーの発生とみなされ、Promiseの失敗を引き起こします。

const simulateAsyncOperation = () => {
    return new Promise((resolve, reject) => {
        const isSuccess = Math.random() > 0.5; // 50%の確率で成功/失敗をシミュレート
        setTimeout(() => {
            if (isSuccess) {
                resolve("成功しました!");
            } else {
      throw new Error("失敗しました!");
            }
        }, 1000);
    });
};

simulateAsyncOperation()
    .then((message) => {
        console.log("成功メッセージ:", message);
    });

console.log("非同期処理中...");

成功しなかった場合の結果

これをエラーハンドリングしたコード例が以下になります。

const simulateAsyncOperation = () => {
    return new Promise((resolve, reject) => {
        const isSuccess = Math.random() > 0.5; // 50%の確率で成功/失敗をシミュレート
        setTimeout(() => {
            if (isSuccess) {
                resolve("成功しました!");
            } else {
                throw new Error("失敗しました!");
            }
        }, 1000);
    });
};

simulateAsyncOperation()
    .then((message) => {
        console.log("成功メッセージ:", message);
    }).catch((error) => {
        console.error("エラーメッセージ:", error.message);
    });;

console.log("非同期処理中...");

成功しなかった場合の結果(エラーハンドリング実行後)

promise をつかったエラーハンドリングにはtry~catch をつかうことができません。

try~catch 構文は 同期的なコード の例外をキャッチするためのもので、非同期で動作する Promise のエラーは try~catch の外で発生します。そのため、Promise のエラーをキャッチするには、Promise 特有のエラーハンドリングメソッドである .catch() を使う必要があります。

他には2個目の引数を設定したり、もう1つthen をチェーンすることでエラーハンドリングを実現することもできます。

こうすることで失敗したpromisecatch でコールバックを追加したことになります。

失敗の可能性のあるPromiseはかならず、.catch()などによるエラーを処理を行い、失敗したPromise を成功に変換しましょう。

thenに2つ目の引数を設定したエラーハンドリング例

simulateAsyncOperation()
.then((message) => {
    console.log("成功メッセージ:", message);
}, (error) => {
    console.error("エラーメッセージ:", error.message); 
});

thenをもう1つチェーンしてエラーハンドリングした例

simulateAsyncOperation()
    .then((message) => {
        console.log("成功メッセージ:", message);
    })
    .then(null, (error) => {
        console.error("エラーメッセージ:", error.message);
    });

async/awaitでのエラーハンドリング

await は使用すると非同期通信が完了するまで次の処理を待ち、Promise を同期的に扱うように見せかけることができるため、promise のエラー処理をcatch メソッドではなく、try~catch 文を使って行うことができます。

さきほどのコードをasync/await で書き直してtry~catch にします。

const simulateAsyncOperation = (): Promise<string> => {
    return new Promise((resolve, reject) => {
        const isSuccess = Math.random() > 0.5; // 50%の確率で成功/失敗をシミュレート
        setTimeout(() => {
            if (isSuccess) {
                resolve("成功しました!");
            } else {
                throw new Error("失敗しました!");
            }
        }, 1000);
    });
};

const runAsyncOperation = async () => {
    try {
        const message = await simulateAsyncOperation();
        console.log("成功メッセージ:", message);
    } catch (error) {
        if (error instanceof Error) {
            console.error("エラーメッセージ:", error.message);
        }
    }
};

runAsyncOperation().then(() => {
    console.log("runAsyncOperation()が完了しました");
});

console.log("非同期処理中...");

成功しなかった場合の結果

動きとしてはsimulateAsyncOperationが成功しなかった場合、await の位置で例外が発生しプログラムが中断します。

すぐ外側でtry に囲まれており、エラーは大域脱出をしてcatch から実行が再開され、失敗しました と表示されます。そこからさらに実行するとrunAsyncOperation() は終了するため、runAsyncOperation() が返したpromise は成功した扱いとなり、console.log("runAsyncOperation()が完了しました") が実行されます。

このように非同期処理を同期処理と同じようにtry~catch でかけるのがasync/await の利点であります。

参考

https://zenn.dev/arei/articles/6f3d0e9a617272

https://zenn.dev/hinoshin/articles/c4a287b4eeaee1

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/instanceof

まとめ

ぼんやりと使っていたエラーハンドリングですが、この記事をまとめるにあたってエラーが実際にどんな動きかを再確認できてよかったです。

今回は長くなってしまってまとめることができなかったfinally ブロックについての後日まとめたいと思っております。

また、弊社ではエンジニアを募集しております。

ぜひカジュアル面談でお話ししましょう!

ご興味ありましたら、ご応募ください!

Wantedly / Green