JavaScript
error
ECMAScript

JavaScript でカスタム例外をしっかり使う

JavaScript には、そこかしこに罠がありますが、その中の1つはエラーハンドリングだと思います。

今回はエラーハンドリングにおいて、あまり活用されていない気がする、カスタム例外をしっかり使ってみたいと思います。

TL;DR

necojackarc/extensible-custom-error を使うと、エラーオブジェクトも引数に取れる便利なカスタム例外が簡単に定義できるよ!

const ExtensibleCustomError = require('extensible-custom-error');

class MyError extends ExtensibleCustomError {}

new MyError('message'); // メッセージ
new MyError(error); // エラーオブジェクト
new MyError('message', error); // メッセージとエラーオブジェクト

エラーオブジェクト内のスタックトレースもマージされて便利!

throw new MyError('Unlimited Blade Works', error);
MyError: Unlimited Blade Works
    at wrapErrorWithMyError (/home/necojackarc/custom_error.js:101:11)
Error: Have withstood Pain to create many Weapons
    at throwBuiltinError (/home/necojackarc/custom_error.js:94:9)
    at wrapErrorWithMyError (/home/necojackarc/custom_error.js:99:5)
    at main (/home/necojackarc/custom_error.js:107:5)
    at Object.<anonymous> (/home/necojackarc/custom_error.js:113:1)
    at Module._compile (module.js:652:30)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)
    at Function.Module._load (module.js:497:3)
    at Function.Module.runMain (module.js:693:10)

エラーハンドリングおさらい

JS の try/catch は他言語に比べるとやや貧弱で、catch 節を1つしか書くことができません。
エラーの型に応じて処理を分岐させたい場合は、以下のように instanceof を使う方法が MDN で紹介されています

try {
  throw new TypeError();
} catch (e) {
  if (e instanceof TypeError) {
    console.log('タイプエラー')
  } else {
    console.log('その他');
  }
}

上記を実行すると、タイプエラーと出力されます。

Promises を利用した場合も同様です。

const rejectPromise = () => new Promise((resolve, reject) => {
  reject(new TypeError());
});

rejectPromise().catch((error) => {
  if (error instanceof TypeError) {
    console.log('タイプエラー');
  } else {
    console.log('その他');
  }
});

こちらもタイプエラーと出力されます。
Async/Await を利用した場合は、通常の try/catch と同様にかけるため、ここでは省略します。

カスタム例外を定義する

カスタム例外を定義し、同様に instanceof で処理を分岐したいと思います。

罠1: 単に継承しただけでは情報が欠損する

JavaScript のカスタム例外は、単に Error を継承しただけでは正しく実装できません。

// 不完全なカスタム例外
class MyError extends Error {}

function throwError() {
  throw new MyError('カスタムエラー');
}

try {
  throwError();
} catch (error) {
  if (error instanceof MyError) {
    console.log(error);
  } else {
    console.log('非カスタムエラー');
  }
}

こちらを実行すると、

Error: カスタムエラー
    at throwError (/home/necojackarc/custom_error.js:6:9)
    at Object.<anonymous> (/home/necojackarc/custom_error.js:10:3)
    at Module._compile (module.js:652:30)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)
    at Function.Module._load (module.js:497:3)
    at Function.Module.runMain (module.js:693:10)
    at startup (bootstrap_node.js:188:16)
    at bootstrap_node.js:609:3

と出力されます。instanceof は正しく動いており、処理の分岐ができているんですが、MyError というエラータイプ情報が欠落しています。

これを回避するために constructorname プロパティの上書き、もしくは V8 では Error.captureStackTrace が利用可能です。

class MyError extends Error {
  constructor(...args) {
    super(...args);

    // this.name = this.constructor.name; でも問題ないが、
    // enumerable を false にしたほうがビルトインエラーに近づく、
    Object.defineProperty(this, 'name', {
      configurable: true,
      enumerable: false,
      value: this.constructor.name,
      writable: true,
    });

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, MyError);
    }
  }
}

こちらを使い、先程と同じコードを実行すると、

MyError: カスタムエラー
    at throwError (/home/necojackarc/custom_error.js:27:9)
    at Object.<anonymous> (/home/necojackarc/custom_error.js:31:3)
    at Module._compile (module.js:652:30)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)
    at Function.Module._load (module.js:497:3)
    at Function.Module.runMain (module.js:693:10)
    at startup (bootstrap_node.js:188:16)
    at bootstrap_node.js:609:3

正しく動きました。

実はこちらも先程の MDN のページに書かれています。

カスタム例外を利用する

定義したカスタム例外を利用したいと思います。

罠2: エラーオブジェクトのコンストラクタが受け取るのはメッセージのみ

JavaScript なのでなんでも渡せるのですが、エラーオブジェクトのコンストラクタは引数にメッセージを取り、エラーオブジェクトを取りません。

つまり、以下のようにビルトイン例外をカスタム例外でラップをしても、意図どおりの挙動にはなりません。

function throwError() {
  throw new Error('ビルトインエラー');
}

try {
  throwError();
} catch (error) {
  throw new MyError(error);
}

これを実行すると、次のエラー出力が得られます。

MyError: Error: ビルトインエラー
    at Object.<anonymous> (/home/necojackarc/custom_error.js:31:9)
    at Module._compile (module.js:652:30)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)
    at Function.Module._load (module.js:497:3)
    at Function.Module.runMain (module.js:693:10)
    at startup (bootstrap_node.js:188:16)
    at bootstrap_node.js:609:3

一見良さそうですが、スタックトレースの情報が欠落してしまっています。
実はこれ、error.toString() を渡した際と同じ結果になっています。

error.toString() の出力はエラータイプ名 + メッセージなので、Error: ビルトインエラー となります。
つまり、エラーオブジェクトを渡したのにスタックトレースは無視されてしまいます。

これに対応するためには引数にエラーオブジェクトが渡された際には、スタックトレースを結合すると言った処理を、コンストラクタに追加する必要があります。

また、当然 Java などで利用可能な throw myError('message', error) のように、メッセージとエラーオブジェクトを渡すような使い方も、JavaScript の標準エラーではできません。

カスタム例外の罠まとめ

  • 単に Error を継承するだけではカスタム例外は不十分
  • エラーのラッピングが標準ではできず情報がかける

罠のない世界

class MyError extends Error {}

とすればカスタム例外が定義でき、

new MyError('message'); // メッセージ
new MyError(error); // エラーオブジェクト
new MyError('message', error); // メッセージとエラーオブジェクト

のように、カスタム例外が利用できる、そんな世界がアレば……!

カスタム例外を簡単に定義できるライブラリ

というわけで、カスタム例外を簡単に定義できるライブラリを作ってみました!

TL;DR に書いたとおり、以下のような使い方ができます。

const ExtensibleCustomError = require('extensible-custom-error');

class MyError extends ExtensibleCustomError {}

new MyError('message'); // メッセージ
new MyError(error); // エラーオブジェクト
new MyError('message', error); // メッセージとエラーオブジェクト

具体例

実際に使ってみます。

まずは引数にエラーオブジェクトを渡してみます。

const ExtensibleCustomError = require('extensible-custom-error');

class MyError extends ExtensibleCustomError {}

function throwBuiltinError() {
  throw new Error('Unknown to Death, Nor known to Life');
}

function wrapErrorWithMyError() {
  try {
    throwBuiltinError();
  } catch (error) {
    throw new MyError(error);
  }
}

function main() {
  try {
    wrapErrorWithMyError();
  } catch (error) {
    console.log(error);
  }
}

main();

上記のコードを実行すると、以下が出力されます。

MyError: Error: Unknown to Death, Nor known to Life
    at wrapErrorWithMyError (/home/necojackarc/custom_error.js:101:11)
Error: Unknown to Death, Nor known to Life
    at throwBuiltinError (/home/necojackarc/custom_error.js:94:9)
    at wrapErrorWithMyError (/home/necojackarc/custom_error.js:99:5)
    at main (/home/necojackarc/custom_error.js:107:5)
    at Object.<anonymous> (/home/necojackarc/custom_error.js:113:1)
    at Module._compile (module.js:652:30)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)
    at Function.Module._load (module.js:497:3)
    at Function.Module.runMain (module.js:693:10)

エラーの情報が残っており、わかりやすい!

次はメッセージとエラーオブジェクトの両方を渡してみます。

const ExtensibleCustomError = require('extensible-custom-error');

class MyError extends ExtensibleCustomError {}

function throwBuiltinError() {
  throw new Error('Have withstood Pain to create many Weapons');
}

function wrapErrorWithMyError() {
  try {
    throwBuiltinError();
  } catch (error) {
    throw new MyError('Unlimited Blade Works', error);
  }
}

function main() {
  try {
    wrapErrorWithMyError();
  } catch (error) {
    console.log(error);
  }
}

main();

そうすると、

MyError: Unlimited Blade Works
    at wrapErrorWithMyError (/home/necojackarc/custom_error.js:101:11)
Error: Have withstood Pain to create many Weapons
    at throwBuiltinError (/home/necojackarc/custom_error.js:94:9)
    at wrapErrorWithMyError (/home/necojackarc/custom_error.js:99:5)
    at main (/home/necojackarc/custom_error.js:107:5)
    at Object.<anonymous> (/home/necojackarc/custom_error.js:113:1)
    at Module._compile (module.js:652:30)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)
    at Function.Module._load (module.js:497:3)
    at Function.Module.runMain (module.js:693:10)

となり、既存のエラー情報の上に、新しいエラー情報がキレイに積み上がっています!

まとめ

JavaScript でカスタム例外をしっかり使うには、エラー情報を欠損させないよう注意が必要です。
そんな罠を確実に回避するため、necojackarc/extensible-custom-error としてライブラリ化してみました。

おあがりよ!