アプリケーションは様々な理由で失敗します。エラーというと負の印象を受けがちですが、アプリケーションのエラーについてはそれほど深刻に捉える必要はなく、JavaScriptにおいてはコンピュータに大きな危害を与えることはまずありません。むしろ考慮していない挙動に遭遇したときに実行を打ち切るので、逆に安全ともいえます。

しかし、実際に動かすアプリケーションでは、エラーが発生するたびに動作を停止していては困ることのほうが多いでしょう。そのため、エラーの発生要因と対処法を抑えて、正しくエラーと付き合っていきましょう。

  • エラーは怖くない
  • エラーによってアプリケーションが意図せず停止することを防ごう

エラーの種類

エラーの発生条件にもさまざまありますが、まずは文法的なエラーです。JavaScriptのコードは実行するときになって初めて検証されるので、正しいコードでなかった場合にはエラーが発生します。これはプログラマが対処しないといけないものです。

計算上のエラーもあります。文法的には正しいものの、計算できないコードや計算結果の取扱いが不適切だった場合に発生するものです。よくあるのは計算結果としてなんらかの意味がある値を期待していたものの、実際には未定義値のnullやundefinedになっていて、その先の計算に失敗するというものです。

実行時のエラーは少々厄介です。文法的にも計算的にも正しいのですが、そのときの状況に応じて失敗するものです。ネットワーク通信の失敗や、形式が不正なデータを変換しようとしたときに発生します。原因の究明に時間や手数を要することもあります。

  • エラーにはいくつか種類があり、それぞれ異なる考慮が必要である
  • 実行時のエラーは面倒臭く、特定が大変なこともある

例外

JavaScriptではエラーの通知に例外を使いますが、正しいコードを記述していれば、あまり発生することはありません。それよりも、ほとんどの時間はエラーを誘発する未定義値のundefinedや非数のNaNといった値の対処に追われることになります。

例外は復帰可能なエラーで、プログラマによる対応が必要です。対応しなかった場合には、通常その場所でコードの実行が打ち切られます。前述のとおり、コード上の誤りや通信エラー、考慮不足によって発生します。

例外が発生したときに、正しく後処理を行うのはプログラマの責任です。対応が漏れると、削除しなければならないデータを削除していなかったり、フラグの管理漏れで不整合が起きたりするでしょう。

例外に対してはtry…catch文で捕捉し、処理を続行できます。catch文では例外オブジェクトを引用することができ、エラーの詳細を確認できるようになっています。

try {
    // fetch APIはステータスコードによらず通常成功するが、
    // 通信自体が失敗すると例外が発生する
    const response = await fetch('https://example.com/')
    const content = await response.text()
    console.log(content)
} catch (e) {
    console.warn(`例外が発生しました: ${e}`)
}

例外は常に捕捉したほうがいいですか?

エラーが発生することがわかっている箇所に対してtry…catch文を書きましょう。むやみに例外捕捉スコープを大きくすることは、捕捉すべきでないエラーを処理してしまいますし、エラーの原因究明が難しくなるおそれがあります。

例外はプログラマ自身が起こすこともでき、それがthrow文です。

throw <任意のオブジェクト>

throwするのは数値でも文字列でもなんでも構いません。通常はErrorクラスやそれを継承したクラスをthrowすることが一般的です。これは慣例の側面もありますし、複合データ型を返すことでより詳細にエラー情報を伝えるためでもあります。

throw new Error('エラーが発生しました')

エラーを誘発する値たち

JavaScriptにはundefined, null, NaNといったそれ自体がエラーを表す値ではないものの、エラーを誘発する値があります。

undefined

JavaScriptの値のひとつで、主に配列やオブジェクトの添字にアクセスした際に、対応する値が存在しないと、この値が返却されます。処理自体は成功しているので、その後どうするかはプログラマの手に委ねられます。

例えば、次のコードは人物のリストを受け取って、年齢ごとにその人数を算出します。ある年齢にはじめてアクセスしたときにはundefinedが返却されるので、これを0扱いにすることで、あらゆる年齢に対して共通した処理をしています。

const persons = [
    { name: 'Alice', age: 14 },
    { name: 'Bob', age: 25 }
]
 
const numberOfOccurs = new Map()
for (const { age } of persons) {
    let occurs = numberOfOccurs.get()
    if (occurs == null) {
        occurs = 0
    }
    numberOfOccurs.set(age, occurs + 1)
}

値がundefinedであるかどうかの判定には==演算子が利用できます。==ではundefinedとnullのどちらかであるかどうかを、===演算子では厳密に判定します。また、null合体演算子 (??)という便利な演算子も使えます。これは、

value != null ? value : <>

の略記で、これを使うと先ほどのコードは

const numberOfOccurs = new Map()
for (const { age } of persons) {
    numberOfOccurs.set(age, (numberOfOccurs.get() ?? 0) + 1)
}

のようにも書けます。つまり、undefinedやnullだったときのデフォルト値を指定できるのです。

null

undefinedに似た値にnullもありますが、undefinedは該当なしという意味合いがあるのに対して、nullは意図的に空の値が存在するということを示しています。今のところ値はないけれども、そのうち値が設定されるかもしれないときにはnullを使います。

nullは歴史的な経緯から未定義値でありながらオブジェクトの性質も持ちます。この特性がしばしば問題を引き起こすので注意が必要です。よくあるのはオブジェクトであることの判定です。

console.log(typeof {} == 'object')    // true
console.log(typeof null == 'object')  // true

nullとほかのオブジェクトと明確に区別する必要があるなら、明示的にnullを除外しなければなりません。

function isNotNullObject(value) {
    return value != null && typeof value == 'object'
}

NaN

NaN (Not a Number)は数値(Number)の特殊な値で、あらゆる数値と等しくない数値です(NaN != NaNです)。数値計算に失敗したときの計算結果で、以下の計算の結果として得られます。

  • 0 / 0 (未定義の計算)
  • parseInt('abc') (数値に変換できない)
  • 'abc' / 3 (文字列への加算以外の数値演算)
  • NaN + 1 (NaNの伝播)

NaNの判定にはisNaN()Number.isNaN()があります(上にも書いたようにNaNとの比較はfalseになります)。より厳密なNaNの判定には後者が推奨されています。

NaNの性質は逆手にも取れます。NaNとの計算はNaNになるので、エラーにならないということでもあります。逐一NaNの判定を入れずとも、最後の結果のみを判定すればいいのです。

  • undefined, null, NaNの特性を理解して正しく処理しよう
  • null合体演算子を使うとデフォルト値を指定できる

エラーが起きたら?

さて、エラーについてなんとなく理解したところで、次にエラーが発生したときにどうするべきかについて考えてみましょう。すべてのエラーが一律に扱えるわけはないので、あるときには処理を継続するでしょうし、またあるときには処理を中止して、エラーの発生理由をしかるべき場所に通知する必要があります。

処理を継続する

ひとつ目の考え方はエラーが発生しても、なんらかの値や処理に置き換えて続行するというものです。これはその処理が任意(オプション)であるときにとれる戦法です。

一例として、JSON.parse()は与えられたデータがJSONとして解釈できなければSyntaxErrorを送出しますが、そのときにtry…catchを使って空のオブジェクトを返すようにすることもできます。

try {
    return JSON.parse(data)
} catch (e) {
    return {}
}

処理を中止する

もうひとつの考え方はエラーが発生したらそこで処理を打ち切るものです。注目している処理が、あとに続く処理の前提条件になっているときには、このようなエラー処理のほうが適しています。

// ここでxが整数であることを期待している
const value = parseInt(x)
if (Number.isNaN(value)) {
    throw new Error('整数を入力してください')
}
 
// valueの100倍を表示する
console.log(value * 100)

エラーを恐れない

エラーメッセージをよく読む

エラーが発生したときに表示されるメッセージはエラーを解決するための最大のヒントとなります。よくプログラミングの初学者は英語だからと読み飛ばす傾向にありますが、内容をしっかり読み込むことでエラーの原因や発生箇所を特定しやすくなります。

言語や環境によってはエラーの発生過程を順に表示してくれるスタックトレース機能もあるので、確認しやすくなっています。

エラーメッセージが役に立たないこともあります

場合によっては何らかの処理の副作用としてまったく関係なさそうなエラーが発生することがあります。この場合、エラーの原因を探るにはコードを少しずつ変更していって、どの変更でそのエラーが発生したのか?を特定するなど、エラーが起こることになったきっかけを見つけることが解決の道筋になるでしょう。

できるかどうかの確認をしない

エラーが起きえる処理に対して「エラーが起きる状況であるか?」を確認して、その結果を信じて処理を実行しないでください。処理にとてつもなく時間がかかるとか、試すことで内部状態が変わってしまう状況では、別の手段を考えなければなりませんが、そういう状況は稀でしょう。

さてここで、なにかデータを書き込みたいという状況が生まれたとします。しかし、複数のユーザーが同時にデータを書き込もうとすると、簡単にデータが破損してしまいます1。なので、ファイルへの書き込みが競合しないように排他ロック2を取ることを思い付きました。すると、次のようなロジックが考えられます。

  1. ファイルのロック状況を確認する
  2. ロックされていればロック解除まで待つかエラーにする
  3. ファイルのロックを獲得する

この手順は完璧にも思えますが、実は穴があります。それは手順1と手順3の間にわずかな時間があることです。つまり、複数のタスクやスレッドが動いている環境では、ふたつのタスクがロックがないと思い込んで、手順3を実行することがあるということです3

手順3で適切なエラー処理をしていれば問題ありませんが、それであれば手順1と2も不要なので、通常は手順3だけを実行して適切なエラー処理をするEAFP(許可を得るよりも許しを請う)に則るほうがコードもシンプルなものになります。

演習

未定義値

  1. 結果がそれぞれundefined, null, NaNとなる簡単な式を書け
  2. 配列 の任意の要素 がNaNか(NaNを含む配列か)どうか判定する式を書け

集計

なんらかの日別データのレポートを作っていて、月ごとに報告データの平均値を確認できるとする。このデータは翌日の適当な時間に作成され始めるとします。

  1. 月の初日だけエラーが発生してレポートを確認できない時間があるという報告が挙がっている。考えられる要因はなにか?
  2. この不具合を修正するにはどのような修正をすればいいか?

銀行口座

  1. 排他ロックが存在しない銀行口座に対して同時に入金と出金をしたときに、起こり得る問題はなにか?
  2. 銀行口座に対して排他ロックを獲得したユーザーが長期間操作をしなかったときにはほかのユーザーが口座を操作できなくなる。この問題にどう対処すればいいか?

第5回 入力値を検証しよう

Footnotes

  1. 誰か(スレッドやプロセス)が優先的にそのリソースへのアクセス権を得ること。

  2. 破損とは実際にどういうことが起きるのか試してみるのもいいでしょう。

  3. 古くはLinuxのシステムコールaccess(2)の注意点でも言及されています。