Edited at

JavaScript/TypeScriptの例外ハンドリング戦略を考える

PySpa統合思念体です。あと、 @yosuke_furukawa にも協力いただきました。

基本的に、あまりエラーの種別を細かく判定してあげることはJavaScriptでは今までやってこなかったのですが、ちょっとしたメタデータを乗っけてあげるとか(例えばリトライ回数)、何か凝ったことをしたくなったらこういう方針でやればいいのでは、という試行錯誤録です。


エラーと例外の区別が必要か

この手の話になると、エラーと例外の違いとか、こっちはハンドリングするもの、こっちはOSにそのまま流すものとかいろんな議論が出てきます。このエントリーではエラーも例外も差をつけずに、全部例外とひっくるめて説明します。

例外というのはすべて、何かしらのリカバリーを考える必要があります。


  • ちょっとしたネットワークのエラーなので、3回ぐらいはリトライしてみる


    • 原因: ネットワークエラー

    • リカバリー: リトライ



  • サーバーにリクエストを送ってみたら400エラーが帰ってきた


    • 原因: リクエストが不正

    • リカバリー(開発時): 本来のクライアントのロジックであればバリデーションで弾いていないといけないのでこれは潰さないといけない実装バグ。とりあえずスタックトレースとかありったけの情報をconsole.logに出しておく。

    • リカバリー(本番): ありえないバグが出た、とりあえず中途半端に継続するのではなくて、システムエラー、開発者に連絡してくれ、というメッセージをユーザーに出す(人力リカバリー)



  • JSONをパースしたらSyntaxError


    • 原因: ユーザーの入力が不正

    • リカバリー: フォームにエラーメッセージを出す



Goとかだと、errをmain()にまで返して、そこでlog.Fatal()みたいな作戦をとったりします。これはある意味プログラムとしては作戦放棄ではありますが、「プログラムの進行が不可能なので、OSに処理を返す」というリカバリーと言えなくもないですよね。例外をキャッチしないでNode.jsのランタイムにエラー表示をまかせる(開発者にスタックトレースを表示して後を託す)、というのもリカバリーの戦術の1つです。

最終的には、実装ミスなのか、ユーザーが間違ってデータ入力したという実行時の値の不正なのか、ネットワークの接続がおかしい、クラウドサービスの秘密鍵が合わないみたいな環境の問題なのか、どれになったとしても、システムが自力でリカバリーする、ユーザーに通知する(人力リカバリー)、開発者に通知する(人力リカバリー)など、何かしらのリカバリーは絶対必要ですよね。

なので、本エントリーではエラーと例外はリカバリーの方法の違いなだけであって、プログラム上の区別はしないという方向で話をすすめます。

バックアップやスナップショットをとるのが大事じゃなくて、リカバリーできることが大事なんだ、とSRE本に書かれていますが、それと同じノリです。


何をやりたいか

リカバリーの方法の選択が必要ということは、例外発生時に、何かしらの情報を付与して、受け取り側でのハンドリングができるようにしてあげることが必要です。

JavaやC++の場合、catch節を複数書くことができ、例外のクラスの種類ごとにリカバリー方法を切り分けることが簡単にできます。

JavaScriptも、Mozillaの独自拡張で昔はそういうのがあったそうです。サンプルはMDNからの引用です。

try {

myroutine(); // 3 つの例外を投げる可能性があります
} catch (e if e instanceof TypeError) {
// TypeError 例外を操作するための文
} catch (e if e instanceof RangeError) {
// RangeError 例外を操作するための文
} catch (e if e instanceof EvalError) {
// EvalError 例外を操作するための文
} catch (e) {
// 任意の指定されていない例外を操作するための文
logMyErrors(e); // エラーハンドラに例外オブジェクトを渡します
}

現状、規格として実現可能な方法は次の方法です。

try {

myroutine(); // may throw three types of exceptions
} catch (e) {
if (e instanceof TypeError) {
// statements to handle TypeError exceptions
} else if (e instanceof RangeError) {
// statements to handle RangeError exceptions
} else if (e instanceof EvalError) {
// statements to handle EvalError exceptions
} else {
// statements to handle any unspecified exceptions
logMyErrors(e); // pass exception object to error handler
}
}

情報としてはクラスがわかると、情報量が上がるのでリカバリーの選択肢が増えます。とはいえ、上流の条件分岐で必要な数だけ例外クラスを作るというのは設計としては逆ですよね?ログ出力のAPIは失敗しても無視でいいけど、データ変更のAPIは失敗時にエラーを出したいとか、下流のAPIの動作に対してどういうリカバリーをしたいかは上流が後から決めなければ責務があべこべです。送信というアクションに対して、送信失敗という例外クラスは1つだけ、どういう失敗をしたのかは例外クラスが持っていて、それを取り出して分析できる、というのが無駄が少なそうです。

axiosなんかはネットワークエラーも、400以上のステータスコードも両方失敗扱いになりますが、error.response.statusやら、error.response.headersなどの追加属性でそれが行えるようになっています。

もちろん標準のErrorクラスでも、テキストは持てるので、それを解析する方法もないこともないですが、いちいちエンコード・デコードの方法を決めるよりは、きちんと属性を定めてしまう方が、データのロスもないです。

なお、属性値があるかどうかというのをTypeScriptで書くのはトリッキーなメタプログラミング(型のためのコード)になりがちです。ばしっと型を決めて追加属性とかにアクセスできるほうが、TypeScriptユーザーのためになるので、クラスの作成は必要だと思います。

まとめると:


  • TypeScript/JavaScriptでは、Javaのように型を指定したcatchはできない

  • instanceofは使える

  • 名前の文字列をパースするとかは避けたい。マシンリーダブルにコードを書きたい。

  • TypeScriptのコード補完も正しく動いて欲しい。


Errorクラスの作成


第一案(失敗)

とりあえず、instanceofで分岐できれば良さそう。Errorの子クラスがあれば良い。

あー、とりあえず Error を継承してればいいんじゃね?

class MyError extends Error {

constructor(e?: string) {
super(e);
}
}

const e = new MyError("test");
console.log("name:", e.name);
console.log("message:", e.message);
console.log("stack:", e.stack);
console.log("instanceof:", e instanceof MyError);

実行結果が何かおかしいですね。nameは継承元の名前。instanceofもfalse。これではダメです。

name: Error

message: test
stack: Error: test
at new MyError (<anonymous>:17:23)
at <anonymous>:23:9
at HTMLButtonElement.excuteButton.onclick (https://www.typescriptlang.org/play/playground.js:247:39)
instanceof: false


第二案(成功)

class MyError extends Error {

constructor(e?: string) {
super(e);
// ↓この2行を足す
this.name = new.target.name;
Object.setPrototypeOf(this, new.target.prototype);
}
}

const e = new MyError("test");
console.log("name:", e.name);
console.log("message:" e.message);
console.log("stack:" e.stack);
console.log("instanceof:", e instanceof MyError);

これで、nameとintanceofが治りましためでたしめでたし。

name: MyError

message: test
stack: MyError: test
at new MyError (<anonymous>:18:28)
at <anonymous>:25:9
at HTMLButtonElement.excuteButton.onclick (https://www.typescriptlang.org/play/playground.js:247:39)
instanceof true

なお、出力ターゲットがES2015以降であれば、 Object.setPrototypeOf() の行がなくても大丈夫です。ただし、その前の this.name = の行はどっちにしても必要です。

なお、この実装は継承しても正しく動作しますし、コンストラクタのもろもろは一度行えば子クラス側でやらなくても大丈夫です。チーム開発のときはここまでは共通でやっておくと良さそう。

class BaseError extends Error {

// 上記の内容
}

// あとはBaseErrorを継承するだけでも正しく動作する
class NetworkAccessError extends BaseError {}
class ServerValidationError extends BaseError {}


マシンリーダブルにする

これで型でのエラー処理判定はできるようになりましたが、これでは追加のメタデータを渡す口がmessageプロパティしかありません。マシンリーダブルな情報の口として、 toJSON() メソッドを足して見ます。

エラー処理はあくまでもエラー処理なので、できれば少ない行数でやりたいですよね。余計なインタフェースを作ったり、サブクラスを作ったりはなるべくしたくない。既存のエラーのサブクラスにメソッドを足すのが簡単そうです。 toJSON() の返り値の型を書かないと、 return 文を見て自動で型が設定されます。

class MyError extends Error {

constructor(private retry: number, e?: string) {
super(e);
this.name = new.target.name;
Object.setPrototypeOf(this, new.target.prototype);
}
toJSON() {
return { retry: this.retry };
}
}


エラー処理の方針

このような定義をしておけば、型ガードを使って判定してあげると、 toJSON() メソッドがコード補完で出てきますし、retry という属性も補完されます。

こんな感じでエラー処理をすれば良いのではないでしょうか?これは try catch で書いているけど、このままawait時のエラーは拾えますし、PromiseやrxJSのcatchでも使えますよね。

try {

// 何か
} catch (e: Error) {
if (e instanceof MyError) {
// ↓ここをタイプするときにtoJSON()もretryも補完で出てくる!
console.log("retry:", e.toJSON().retry);
}
}

コーディング規約としては、第2案のようなエラークラスを必要に応じて作る、throwで Error のインスタンスおよび、 Error を継承した子クラス以外は投げない、というのを入れても良さそう。