😊

JavaScriptのエラーと友達になるために必要なこと

2022/11/06に公開約16,400字

はじめに

とあるマンガの有名なセリフで 「ボールはともだち こわくないよ」という言葉があります。
この言葉を引用して 「エラーはともだち こわくないよ」 があっても違和感ないでしょう。
JavaScript のエラーについて詳しく知り、エラーの原因を特定するためにどのようにエラーと向き合えばよいか、効率的にエラーをハンドリングするため方法も解説します。

以下のようなことを解説しています。

  • エラーに関して

    • Errorオブジェクトの基礎となる要素
    • スタックトレースの読み方
    • エラーと例外について
    • エラーのタイプについて
  • エラーの対処について

    • try, catch, finallyについて
    • グローバルにエラー処理をする onerrorメソッド について
    • callback と promise でのエラーの対処について
    • どの状況下で、エラーの対処方法を選定するのか
  • エラーを効率的に解決するためのハンドリングについて

想定読者

  • JavaScript ではない他プログラミング言語を既に習得しており、JavaScript のエラーについて勉強したい方
  • JavaScript を既に習得しているが、再度 JavaScript のエラーを勉強したい方

そもそもエラーとはなんだろう

Webアプリケーション開発におけるエラーとは、プログラムが正常に動作していない状況のことです。

具体的な例として、

  • 存在しないファイルを読み込もうとすると Module not found: Error: Can't resolve ~
  • ネットワークに接続されていない状況でWebサイトにアクセスしようと net::ERR_INTERNET_DISCONNECTED

上記の場合だと、プログラムが正常に指示された動作を行うことができない状況です。

このような状況に遭遇するとプログラムは開発者に対しエラーを投げ、「そのプログラムだと問題が発生する」と伝えます。
エラーが発生した原因の情報が収集され、エラーとして表示してくれるのです。

ですが、WebアプリケーションのユーザーがWeb開発がよく見るようなエラーを読むことがないように、対処するのは大切でしょう。
わかりやすいメッセージとして「ページが見つかりませんでした」などが表示します。

Webアプリケーションのわかりやすいメッセージの具体例
スクリーンショット 2022-11-05 21 02 39

Errorオブジェクトについて

javascript のエラーに詳しくなりましょう。ここでの javascript のエラーは、エラーが発生した時に表示されるオブジェクトのことを指しています。
そのオブジェクトには、以下の情報で構成されています。

  • エラーの種類
  • エラーの原因(ステートメント/statement)
  • スタックトレースに関する情報

extends を使用すると、Errorクラス を拡張させて、 Errorオブジェクト に手を加えて、プログラミングの問題を調査する時に表示される情報量を増やすことができます。(後半のエラーを拡張しようで詳細に解説します。)

エラーのプロパティ(インスタンスプロパティ)について

JavaScriptのより詳細を解説していきます。
エラーは主に3つから成り立っています。

  • Message: 人間が読めるエラーの簡潔な説明
  • Name: エラーの種類の名称で初期値は "Error"
  • Stack: エラーは発生するまでに呼び出しされたメソッドのスタックトレース

スタックトレースについて

スタックトレースとは、例外や警告などのイベントが発生したときに、直前にプログラムが実行していたメソッドを一覧で示したものです。

スタックトレースは主に3つから成り立っています。

  • 問題が発生する前の最後のステップ
  • エラーの型とメッセージ
  • ファイルのパス, 行番号、カラム数

具体例として、

$ node demo.js

/Users/jp-knj/Desktop/demo.js:10
  notDefined();
  ^
RerefenceError: notDefined is not defined 
    at thirdFunction (/Users/jp-knj/Desktop/demo.js:10:3)
    at secondFunction (/Users/jp-knj/Desktop/demo.js:6:3)
    at firstFunction (/Users/jp-knj/Desktop/demo.js:2:3)
    // 以下 省略

エラー名とメッセージとして RerefenceError: notDefined is not defined が表示されます。
その後に呼び出されたメソッドの一覧が表示されます。 各メソッド呼び出しには、ファイルのパス、、行番号、カラム数を確認できます。
上記のスタックトレースをより詳細に解説すると

at thirdFunction              // 呼び出された各メソッド
/Users/jp-knj/Desktop/demo.js // ファイルのパス
~/demo.js:10:3                // ファイル名、行番号、カラム数 

表示された情報を元に code jump して、どのコードがエラーの原因であるかを特定するのができるのです。

このメソッドは、処理の順序を反映するかたちで表示されます。これにより、例外が発生した箇所と、それがどのように伝搬していったのかを確認できるのです。

例外の catch を実装すれば、例外がスタックを伝搬しプログラムをクラッシュさせてしまうことはありません。 しかし、致命的なエラーをそのままにし、意図的にプログラムをクラッシュさせるときもあります。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Error/Stack

エラーと例外の重要な違いについて

私自身、エラーと例外との違いを曖昧にしていた開発者です。ですが、重要な違いがありますので理解しておきましょう。

  • エラーとは、プログラムが正常に動作していない状況のこと
  • 例外とは、投げられたエラーオブジェクトのこと

具体的な例として、

const demoError = TypeError('Hello', "someFile.js", 10)

throw demoError 

demoErrorrオブジェクトが例外です。

業務で使用する場合は、糖衣構文で使っていることが多いのではないでしょうか。基礎となる Errorオブジェクト について理解しておくと、糖衣構文を使った場合でも混乱することはないです。

throw TypeError('Hello', "someFile.js", 10)

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/TypeError

JavaScript にはどんなエラーがあるのだろうか

JavaScriptには、定義されたさまざまなエラーのタイプがあります。 これは、Webアプリケーションで明示的にエラーの処理を行い限り、JavaScriptランタイムによって自動的に選択、定義されます。

JavaScriptで主なエラーのタイプ、各タイプはいつ、どのような理由で発生するのかを解説していきます。

TypeError

このエラーのタイプはよく開発者も多いのではないでしょうか。
変数や引数の型が想定された型でないことが検出されたときに表示されます。

具体例として

TypeError: Object doesn't support property or method {x} (Edge)
TypeError: "x" is not a function

関数でないものを、関数呼び出ししようとしたときに発生するエラーです。
特定の関数が定義されていることを想定しているが、定義されていないときも発生するでしょう。
スクリーンショット 2022-11-05 23 29 10

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Errors/Not_a_function

ReferenceError

コード内の変数の参照に問題があるときに表示されます。
変数を使用する前に値を定義するのを忘れたか、またはコードでアクセスできない変数を使用しようとしている可能性があります。
いずれにしても、スタックトレースを確認することで、問題のある変数の参照を見つけ、修正にあたることができます。

具体例として

ReferenceError: "x" is not defined

スクリーンショット 2022-11-05 23 26 46
変数は現在の実行コンテキストで利用可能である必要があります。関数の中で定義された変数は、その関数のスコープ内でしか定義されていないので、関数の外のどこからもアクセスできません。

RangeError

変数に有効な値の範囲外にある値が設定されたときに表示されます。
関数に引数として値を渡すときに発生し、与えられた値が関数のパラメータの範囲内にないことを示します。正しい値を渡すためには、引数に取り得る値の範囲を知る必要があるため、サードパーティのライブラリのドキュメントが不十分な場合、このエラーの解決には手間がかかる可能性があります。

RangeError: Invalid array length

スクリーンショット 2022-11-05 22 58 46

a.length(配列の要素の数)を負の数にして、対応している値を超えてしまった

Arrayコンストラクタで不正な長さの配列を作成しようとした
toExponential()、toPrecision()、toFixed()などのメソッドに不正な値を渡した

SyntaxError

修正するのが最も簡単なものエラーです。JavaScriptはスクリプト言語ですので、スクリプトにあるエラーがあると、実行時に、このようなエラーが返されます。
コンパイル言語では、このようなエラーはコンパイル時に発見されます。したがって、エラーが解決されるまで、アプリケーションのバイナリを生成することはないでしょう。

SyntaxError: expected expression, got "x"

スクリーンショット 2022-11-05 23 27 18
eslint を使って、発見しましょう

InternalError

InternalErrorは、JavaScriptのランタイムエンジンで例外が発生したときに表示されます。
これはコードの問題を意味することも、そうでないこともあります。

RangeError: Maximum call stack size exceeded

具体例として、関数の呼び出しが多すぎるときに表示されます。
スクリーンショット 2022-11-05 23 27 40

JavaScriptエンジンにとって大きすぎるエンティティがコード内に存在する(例:switch文のcaseの分岐が多すぎる、配列初期化子が大きすぎる、再帰が多すぎる)

JavaScriptエンジンの作業負荷が急激に上昇してしまうのを避けましょう。

URIError

URIErrorは、decodeURIComponentのようなグローバルなURI処理関数が間違った方法で使用されたときに表示されます。
メソッド呼び出しに渡されたパラメータがURI標準に準拠しておらず、メソッドによって適切にパースされなかったことを意味します。

URIError: URI malformed

スクリーンショット 2022-11-05 23 28 10

引数に問題がないかを調べるだけで対処できるので簡単です。

EvalError

後方互換性のために存在しています。
現在のECMAScriptの仕様ではEvalErrorクラスは返されないため、あくまでも古いバージョンで作業すると、直面するかもしれないエラーになります。
eval()メソッドの呼び出しで実行されたコードに例外がないかどうか調査するために頭の片隅に置いておく程度でよいかもしれません。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/EvalError

エラーを拡張しよう

ある程度の状況を網羅できるだけのエラーのタイプクラスがあります。ですが、必要であれば、extendsを使用して拡張したエラータイプを定義できます。 (JavaScript が number, string、Errorオブジェクト など、何でも throw することができるおかげですね)

以下のような、プリミティブ型ではそのエラーのタイプや名前、スタックトレースはなく、エラーが発生した詳細についてはわかりません。
こんな時に、Errorクラスを使用します。

throw "An error occurred"

Errorクラスを拡張(extends)して、独自のエラークラスを定義します。

具体例として

class ValidationError extends Error {
    constructor(message) {
        super(message);
        this.name = "ValidationError";
    }
}

定義した ValidationErrorを使用すると

throw ValidationError("Property not found: name")

そして instanceofキーワード と組み合わせます。

try {
    validateDemo() // ValidationError を投げるかもしれない処理
} catch (e) {
    if (e instanceof ValidationError) {
        // エラーハンドリングの処理 
    } else {
        // エラーハンドリングの処理
    }
}

エラーの対処方法を知ろう

開発する上で、どのようにエラーを検出し、防止するかを理解しておくことが重要です。

エラーの対処方法については主に3つほどあります

  • throw/catch
  • onerror()
  • callback/promise

各対処方法を解説していき、さいごに選定する基準を紹介させていただきます。

throw/catch について

他言語と同様に、JavaScript にはエラーの処理に使える機能がいくつもあります。エラー処理について話す前に、throw と catch するために必要になる機能を理解しておきましょう。

throw について

名前からも明らかなように、throw は JavaScript で例外を投げるのに使用します。

number, string、Errorオブジェクト など、何でも throw することができる
しかし、number や string などのプリミティブ型は、エラーに関するデバッグ情報を持たないため、投げるのは効果が高くはないのではないでしょうか。

throw TypeError("Please provide a string") 

try について

コードが例外を投げる可能性があることを示すために使用されます。

try {
// 例外を投げるかもしれない処理 
}

catch について

ブロックをキャッチするのに使用します。つまり、 tryブロック により捕捉されたエラーを処理する役割を果たします。

catch (exception) {
// 例外をハンドリングする処理
}

そして、 try と catch をあわせて使用すると以下のようになります。

try {
// 何かしらの処理 
} catch (exception) {
// エラーハンドリングの処理
}

1つの catchブロック の中で if...else文 や switch文 を使って、起こりうるすべてのエラーのパターンを処理することができます。

try {
// 何かしらの処理 
} catch (exception) {
    if (exception instanceof TypeError) {
        // エラーのタイプが TypeError のときのハンドリング 
    } else if (exception instanceof RangeError) {
        // エラーのタイプが RangeError のときのハンドリング  
    }
}

finally について

finally は、エラー処理後に実行されるコードブロックの定義に使用します。
なので、try → catch → finally の順で実行されます。

また、finally は try, catch の結果に関係なく実行されるのです。
以下の場合であっても、クラッシュする前にインタプリタにより finally が実行されます。

  • catch がエラーを処理できないとき
  • catch でエラーが発生したとき

ちなみに try は、catch や finally が存在しなと、インタプリタによりSyntaxErrorが返されます。

onerrorメソッドについて

グローバルにエラーを処理するときに、必要なのは onerror()メソッドです。HTML要素で発生するエラーを処理するのに使用できます。
具体例として、imgタグ が指定されたURLの画像を見つけられなかった場合、 onerrorメソッド を呼び出します。こうすると、ユーザーによるエラーの処理が可能になるのです。

imgタグ がフォールバックできるように、 onerror で別の画像URLを用意します。

let script = document.createElement('script');
script.src = "https://example.com/404.js"; // こんなスクリプトはありません
document.head.append(script);

script.onerror = function() {
    alert("Error loading " + this.src); // Error loading https://example.com/404.js
};

具体例としてのグローバルにエラー処理するハンドラ関数

window.onerror = (event) => {
    console.log("Error occurred: " + event)
}

このハンドラ関数を使用すると、コード内にある複数の try catch を除いた、イベント処理とエラー処理を一元化することができるようになります。
もちろん、単一責任の原則に沿って複数のエラーハンドラを window に関連することもできます。 (特定のハンドラに到達するまで、インタプリタがハンドラを順に追跡するらしいです。)

https://developer.mozilla.org/ja/docs/Web/API/Window/error_event
https://ja.javascript.info/onload-onerror

余談ではありますが、imgタグでの画像読み込みエラーonerrorメソッドについて解説されてありますが、ブラウザーの互換性 では image の onerrorメソッド を追加するのは非推奨だったりします。(ドキュメントの更新がされてないんでしょうか)

callback について

具体的をみていきましょう

const calculateCube = (number, callback) => {
    setTimeout(() => {
        const cube = number * number * number
        callback(cube)
    }, 1000)
}

const callback = result => console.log(result)

calculateCube(4, callback)

上記の関数では、処理に時間を取り、 callback を利用することで後から結果を返すという非同期の仕組みを実装しています。

calculateCube() に4ではなく文字列を入力すると、結果として NaN が返されます。

それを対処しようとすると

const calculateCube = (number, callback) => {
    setTimeout(() => {
        if (typeof number !== "number")
            throw new Error("Numeric argument is expected")

        const cube = number * number * number
        callback(cube)
    }, 1000)
}

const callback = result => console.log(result)

try {
    calculateCube(4, callback)
} catch (e) { 
    console.log(e)
}

問題が解決するはずです。ですが、calculateCube()に文字列を渡そうとすると、次のようになります。
スクリーンショット 2022-11-06 18 36 48

try, catch を実装しているにもかかわらず、Uncaught Error: Numeric argument is expectedと表示されました。
setTimeout()メソッドによる遅延のため、 catch が実行された後にエラーが投げられます。
これは、予期せぬ遅延があるため発生し、このような状況を網羅しながら、業務でアプリケーションを開発する必要があります。

コールバックでエラーを処理する方法は、次の通りです。

const calculateCube = (number, callback) => {
    setTimeout(() => {
        if (typeof number !== "number") {
            callback(new TypeError("Numeric argument is expected"))
            return
        }
        const cube = number * number * number
        callback(null, cube)
    }, 2000)
}

const callback = (error, result) => {
    if (error !== null) {
        console.log(error)
        return
    }
    console.log(result)
}

try {
    calculateCube('hey', callback)
} catch (e) {
    console.log(e)
}

コンソールの出力は
スクリーンショット 2022-11-06 18 39 15

赤いエラーのメッセージは表示されなくなり、適切に処理されたことをわかります。

promise中のエラーの処理

promise は reject されてもプログラミングが終了することはない、という強みがあります。しかし、promise のエラーを処理するために、catch を追加する必要があります。
理解を深めるために、 promise を使用してcalculateCube()メソッドを追加しましょう。

const delay = ms => new Promise(res => setTimeout(res, ms));

const calculateCube = async (number) => {
    if (typeof number !== "number") 
        throw Error("Numeric argument is expected")
    await delay(5000)
    const cube = number * number * number
    return cube
}

try {
    calculateCube(4).then(r => console.log(r))
} catch (e) {
    console.log(e)
}

delay()メソッドを追加して、4の代わりに文字列を入力しようとすると、出力は以下のようになります。

promiseの引数に誤りがある場合のTypeErrorの例
ここでも、他のすべての処理が完了した後に promise がエラーを投げることが原因となっています。この問題の解決方法は簡単です。以下のように promiseチェーン に catch() 呼び出しを追加するだけでOKです。

calculateCube("hey")
    .then(r => console.log(r))
    .catch(e => console.log(e))

これで、出力は以下のようになります。

誤った引数を利用したことによる TypeError の対処例
promiseを用いたエラー処理がいかに簡単であるかをご理解いただけたはずです。さらに、finally()ブロック と promise呼び出しを連鎖させ、エラー処理完了後に実行するコードを追加することもできます。

また、従来型のtry-catch-finallyを用いて、promiseのエラー を処理することもできます。その場合の promise 呼び出しは以下のようになります。

try {
    let result = await calculateCube("hey")
    console.log(result)
} catch (e) {
    console.log(e)
} finally {
    console.log('Finally executed")
}

これは非同期関数の中でのみ機能します。のエラーを処理する最適解は、promise呼び出しに、catch と finally を連鎖させることです。

状況ごとにエラーの対処方法を使い分けることなるでしょう。上記で解説した throw/catch, onerror(), callback,promiseでの対処方法を選定する基準を紹介させてください。

throw/catch を使う状況について

ほとんどの状況で、これを使用することになるのではないでしょうか。 catch の中で、起こりうるすべてのエラーに対する処理を実装するようになるでしょう。また、try の後にメモリのクリーンアップを行う必要がある場合は、finally の追加するのは忘れてはならないでしょう。ですが、 try, catch が多すぎると、保守の難易度が高くなります。そのような場合は、onerror()メソッド や promise でエラーを処理することをおすすめします。 非同期 try、catch と promise の catch()メソッド のどちらを選択するについてですが、非同期 try、catch の方が、コードが直感的になり、デバッグしやすいので推奨します。

onerror() を使う状況について

onerror() メソッドは、アプリケーションで多くのエラーを処理する必要があり、問題の箇所がコードベース全体に散らばっていることが分かっている場合に推奨する対処方法ではないでしょうか。onerror() メソッドを使用すると、エラーをアプリケーション内のイベントのように扱うことができます。複数のエラーハンドラを定義して、最初のレンダリング時にアプリの window に紐付けることができるためです。小規模なプロジェクトでは、エラーの範囲が狭いため onerror()メソッド の設定が余計に難しくなるのはないでしょうか。try、catch を使用していき、複雑になり始めたら onerror()メソッドを検討するでよいです。

callbackとpromise を使う状況について

callback と promise のエラー処理は、設計や構造から異なります。コードを書く前にこの2つのどちらかを選ぶのであれば、promise を選ぶのが安全ではないでしょうか。promise には catch と finally を連鎖させてエラーを簡単に処理する仕組みが組み込まれています。この対処方法は、エラーを処理するために引数を定義したり、既存の引数を再利用したりするよりも簡潔です。

コードベース内のミスでエラーが発生することは、必ず直面します。開発中やデバッグ中に、不必要な変更を加えてしまい、それが原因で新たなエラーが発生することすらあります。変更を加えるたびに自動でのテストを実行しておく方法もあるでしょう。ただし、これにより分かるのは、問題があるかないかという実行結果だけです。コードレビューでもエラーを取り除けるようにしたいですね。

エラーを解決しやすくするために

エラー解決の効果を最大化するためには、実装の際にいくつかの点に留意したいところです。
以下に具体的な例を上げています。

Errorクラスを拡張して、独自のエラークラスを定義しよう

記事の前半に解説させていただきました。Webアプリケーション固有の状況に合わせてエラー処理の対処方法を調整できるようになるためです。
デフォルトである Errorクラス ではなく、可能な限り拡張したエラータイプを使用することを推奨します。そうすることで、よりも多くの情報や背景が確認できるようになります。
Webフロントエンドでは、契約(契約による設計)に沿って実装されているプロジェクトもあります。
Errorクラス を拡張して、事前条件エラーを定義して意図を伝えるようにもなります。

class PreConditionError extends Error {}

エラーの表示方法に手を加えて、エラーに関するもっと詳しい情報を表示したりできます。エラーの解釈や処理の方法を細かく制御してもよいでしょう。

例外を放置しない

コードの奥深くで例外をほったらかしにしてしまう、という初歩的なミスを犯した経験がある開発者は多いのではないでしょうか。

実行が任意である処理があったとします。
このような場合、この処理を try に入れ、空の catch でその場しのぎをしたくなるかもしれません。そうすると、その処理からがあらゆる種類のエラーを引き起こすでしょう。
処理が肥大化したとき、エラーハンドリングされてないときは大きなリスクとなります。例外を処理する上でのベストプラクティスは何をすることでしょうか。すべての例外を処理する層を決定し、そこに至るまでに、例外処理に投げることです。この層は、コントローラ、ミドルウェア でもよいでしょう。
アプリケーションで発生しているすべてのエラーの場所を把握しながら対処法を賢く選択できるようになります。なにもしていなくても次の指針は示しやすくなります。

ログとエラーアラートを一元化する

エラーの処理における大事な要素として、ログを取ることが挙げられます。エラーのログを一元的に管理しないと、アプリケーションの使用状況に関する貴重な情報を見逃してしまうかもしれません。
アプリケーションのイベントログを確認することで、エラーに関する重要なデータを把握し、素早いデバッグに役立てることができます。
アプリケーションに適切なアラートメカニズムを設定しておいて、エラーが発生したときに、それが多くのユーザーに影響を及ぼす前に把握するのが理想です。

デフォルトの logger() を使用するか、好みにあわせて logger を定義することを推奨します。
logger の中には、エラーのレベル (警告、デバッグ、情報など) に応じて処理方法を設定でき、中には logg をすぐにリモートロギングのサーバーに送信するものさえあります。
このようにして、アプリケーションの処理がアプリケーションの利用者に対してどのように実行されるかを監視することができるのです。

ユーザーに対し適切にエラーを通知する

エラー処理方法を定義する際に、ユーザーを念頭に置く必要があります。 アプリケーションの正常な機能を妨げるすべての問題は、目に見えるかたちで(アラートやエラーメッセージで)確認できるようにしましょう。
これにより、ユーザーによる解決策の実施が促せます。操作の再試行やログアウトと再ログインなど、エラーの簡単な解決方法を知っている場合は、アラートに必ず実装します。
日常的な使用に支障をきたさないエラーの場合、アラートは出さずに、後で解決できるように、ログを取っておくこともしたいですね。

さいごに

エラーは異物ではなく自然と発生するものです。場合によっては、ユーザーへの反応として、意図的にエラーを投げる必要があることさえあります。なので、エラーの構造と種類を理解することは非常に重要です。
アプリケーションを停止させてしまうエラーを特定し、それを防ぐための知識と道具を用意しておく必要があります。

「エラーは ともだち こわくないよ」

でも、時々理由もなく怒るのはやめてほしいです

間違った知識や内容がありましたら、ご指摘ください。

GitHubで編集を提案

Discussion

ログインするとコメントできます