2023年8月7日

カスタムエラー、Errorの拡張

開発を行う際、タスクで発生する可能性のある特定の問題を反映するために、独自のエラークラスが必要になることがよくあります。ネットワーク操作のエラーには`HttpError`、データベース操作には`DbError`、検索操作には`NotFoundError`などが必要になる場合があります。

エラーは、`message`、`name`、できれば`stack`などの基本的なエラープロパティをサポートする必要があります。ただし、独自の他のプロパティを持つこともできます。たとえば、`HttpError`オブジェクトは、`404`、`403`、`500`などの値を持つ`statusCode`プロパティを持つ場合があります。

JavaScriptでは、`throw`を任意の引数で使用できるため、技術的にはカスタムエラークラスは`Error`から継承する必要はありません。ただし、継承すると、`obj instanceof Error`を使用してエラーオブジェクトを識別できるようになります。そのため、継承することをお勧めします。

アプリケーションが成長するにつれて、独自のエラーは自然に階層を形成します。たとえば、`HttpTimeoutError`は`HttpError`から継承するなどです。

Errorの拡張

例として、ユーザーデータを含むJSONを読み取る関数`readUser(json)`を考えてみましょう。

有効な`json`の例を次に示します。

let json = `{ "name": "John", "age": 30 }`;

内部的には、`JSON.parse`を使用します。不正な形式の`json`を受け取った場合、`SyntaxError`がスローされます。しかし、`json`が構文的に正しい場合でも、それが有効なユーザーであるとは限りません。必要なデータが不足している可能性があります。たとえば、ユーザーに不可欠な`name`プロパティと`age`プロパティがない場合があります。

関数`readUser(json)`は、JSONを読み取るだけでなく、データをチェック(「検証」)します。必要なフィールドがない場合、または形式が間違っている場合は、エラーになります。そして、それは`SyntaxError`ではありません。データは構文的に正しいですが、別の種類のエラーです。これを`ValidationError`と呼び、クラスを作成します。この種のエラーは、問題のあるフィールドに関する情報も伝える必要があります。

`ValidationError`クラスは、`Error`クラスから継承する必要があります。

`Error`クラスは組み込みですが、拡張している内容を理解できるように、おおよそのコードを次に示します。

// The "pseudocode" for the built-in Error class defined by JavaScript itself
class Error {
  constructor(message) {
    this.message = message;
    this.name = "Error"; // (different names for different built-in error classes)
    this.stack = <call stack>; // non-standard, but most environments support it
  }
}

それでは、`ValidationError`を継承し、実際に使用してみましょう。

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

function test() {
  throw new ValidationError("Whoops!");
}

try {
  test();
} catch(err) {
  alert(err.message); // Whoops!
  alert(err.name); // ValidationError
  alert(err.stack); // a list of nested calls with line numbers for each
}

注意:行`(1)`では、親コンストラクターを呼び出しています。JavaScriptでは、子コンストラクターで`super`を呼び出す必要があるため、これは必須です。親コンストラクターは`message`プロパティを設定します。

親コンストラクターは`name`プロパティも`"Error"`に設定するため、行`(2)`で正しい値にリセットします。

`readUser(json)`で使用してみましょう。

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

// Usage
function readUser(json) {
  let user = JSON.parse(json);

  if (!user.age) {
    throw new ValidationError("No field: age");
  }
  if (!user.name) {
    throw new ValidationError("No field: name");
  }

  return user;
}

// Working example with try..catch

try {
  let user = readUser('{ "age": 25 }');
} catch (err) {
  if (err instanceof ValidationError) {
    alert("Invalid data: " + err.message); // Invalid data: No field: name
  } else if (err instanceof SyntaxError) { // (*)
    alert("JSON Syntax Error: " + err.message);
  } else {
    throw err; // unknown error, rethrow it (**)
  }
}

上記のコードの`try..catch`ブロックは、`ValidationError`と`JSON.parse`からの組み込み`SyntaxError`の両方を処理します。

行`(*)`で`instanceof`を使用して特定のエラードウタイプをチェックする方法に注目してください。

次のように`err.name`を見ることもできます。

// ...
// instead of (err instanceof SyntaxError)
} else if (err.name == "SyntaxError") { // (*)
// ...

`instanceof`バージョンの方がはるかに優れています。将来的には`ValidationError`を拡張し、`PropertyRequiredError`などのサブタイプを作成するからです。そして、`instanceof`チェックは、新しく継承するクラスでも引き続き機能します。そのため、将来に対応できます。

また、`catch`が不明なエラーに遭遇した場合、行`(**)`でそれを再スローすることが重要です。`catch`ブロックは検証エラーと構文エラーの処理方法のみを知っており、他の種類(コードのタイプミスまたはその他の不明な理由による)はフォールスルーする必要があります。

さらなる継承

`ValidationError`クラスは非常に一般的です。多くのことがうまくいかない可能性があります。プロパティが存在しないか、形式が間違っている可能性があります(数値ではなく`age`の文字列値など)。存在しないプロパティ専用の、より具体的なクラス`PropertyRequiredError`を作成しましょう。不足しているプロパティに関する追加情報が提供されます。

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

class PropertyRequiredError extends ValidationError {
  constructor(property) {
    super("No property: " + property);
    this.name = "PropertyRequiredError";
    this.property = property;
  }
}

// Usage
function readUser(json) {
  let user = JSON.parse(json);

  if (!user.age) {
    throw new PropertyRequiredError("age");
  }
  if (!user.name) {
    throw new PropertyRequiredError("name");
  }

  return user;
}

// Working example with try..catch

try {
  let user = readUser('{ "age": 25 }');
} catch (err) {
  if (err instanceof ValidationError) {
    alert("Invalid data: " + err.message); // Invalid data: No property: name
    alert(err.name); // PropertyRequiredError
    alert(err.property); // name
  } else if (err instanceof SyntaxError) {
    alert("JSON Syntax Error: " + err.message);
  } else {
    throw err; // unknown error, rethrow it
  }
}

新しいクラス`PropertyRequiredError`は簡単に使用できます。プロパティ名を渡すだけです:`new PropertyRequiredError(property)`。人間が読める`message`は、コンストラクターによって生成されます。

`PropertyRequiredError`コンストラクターの`this.name`は、再び手動で割り当てられていることに注意してください。すべてのカスタムエラークラスで`this.name = <クラス名>`を割り当てるのは少し面倒になる可能性があります。`this.name = this.constructor.name`を割り当てる独自の「基本エラー」クラスを作成することで、これを回避できます。そして、そこからすべてのカスタムエラーを継承します。

それを`MyError`と呼びましょう。

`MyError`と他のカスタムエラークラスを含む簡略化されたコードを次に示します。

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

class ValidationError extends MyError { }

class PropertyRequiredError extends ValidationError {
  constructor(property) {
    super("No property: " + property);
    this.property = property;
  }
}

// name is correct
alert( new PropertyRequiredError("field").name ); // PropertyRequiredError

コンストラクターの` "this.name = ..." `行がなくなったため、カスタムエラーははるかに短くなりました。特に`ValidationError`は短くなりました。

例外のラップ

上記のコードの関数`readUser`の目的は、「ユーザーデータを読み取る」ことです。プロセス中にさまざまな種類のエラーが発生する可能性があります。現在、`SyntaxError`と`ValidationError`がありますが、将来的には`readUser`関数が成長し、おそらく他の種類のエラーが発生する可能性があります.

`readUser`を呼び出すコードは、これらのエラーを処理する必要があります。現在、`catch`ブロックで複数の`if`を使用しており、クラスをチェックして既知のエラーを処理し、未知のエラーを再スローします。

スキームは次のとおりです。

try {
  ...
  readUser()  // the potential error source
  ...
} catch (err) {
  if (err instanceof ValidationError) {
    // handle validation errors
  } else if (err instanceof SyntaxError) {
    // handle syntax errors
  } else {
    throw err; // unknown error, rethrow it
  }
}

上記のコードでは、2種類のエラーが表示されていますが、もっと多くのエラーがある可能性があります。

`readUser`関数がいくつかの種類のエラーを生成する場合、次の質問をする必要があります。本当に毎回すべてのエラードウタイプを1つずつチェックしたいですか?

多くの場合、答えは「いいえ」です。「そのすべてよりも1レベル上」になりたいのです。 「データ読み取りエラー」が発生したかどうかを知りたいだけです。なぜそれが発生したのかは、多くの場合無関係です(エラーメッセージに記載されています)。または、さらに良いことに、エラーの詳細を取得する方法が欲しいのですが、必要な場合にのみです。

ここで説明する手法は、「例外のラップ」と呼ばれます。

  1. 一般的な「データ読み取り」エラーを表す新しいクラス`ReadError`を作成します。
  2. 関数`readUser`は、内部で発生するデータ読み取りエラー(`ValidationError`や`SyntaxError`など)をキャッチし、代わりに`ReadError`を生成します。
  3. `ReadError`オブジェクトは、`cause`プロパティで元のエラーへの参照を保持します。

その後、`readUser`を呼び出すコードは、あらゆる種類のデータ読み取りエラーではなく、`ReadError`のみをチェックする必要があります。エラーの詳細が必要な場合は、`cause`プロパティを確認できます。

`ReadError`を定義し、`readUser`と`try..catch`での使用方法を示すコードを次に示します。

class ReadError extends Error {
  constructor(message, cause) {
    super(message);
    this.cause = cause;
    this.name = 'ReadError';
  }
}

class ValidationError extends Error { /*...*/ }
class PropertyRequiredError extends ValidationError { /* ... */ }

function validateUser(user) {
  if (!user.age) {
    throw new PropertyRequiredError("age");
  }

  if (!user.name) {
    throw new PropertyRequiredError("name");
  }
}

function readUser(json) {
  let user;

  try {
    user = JSON.parse(json);
  } catch (err) {
    if (err instanceof SyntaxError) {
      throw new ReadError("Syntax Error", err);
    } else {
      throw err;
    }
  }

  try {
    validateUser(user);
  } catch (err) {
    if (err instanceof ValidationError) {
      throw new ReadError("Validation Error", err);
    } else {
      throw err;
    }
  }

}

try {
  readUser('{bad json}');
} catch (e) {
  if (e instanceof ReadError) {
    alert(e);
    // Original error: SyntaxError: Unexpected token b in JSON at position 1
    alert("Original error: " + e.cause);
  } else {
    throw e;
  }
}

上記のコードでは、`readUser`は説明どおりに機能します。構文エラーと検証エラーをキャッチし、代わりに`ReadError`エラーをスローします(未知のエラーは通常どおり再スローされます)。

そのため、外部コードは`instanceof ReadError`をチェックするだけで済みます。すべての可能なエラードウタイプをリストする必要はありません。

このアプローチは「例外のラッピング」と呼ばれます。「低レベル」の例外を取得し、より抽象的な`ReadError`に「ラップ」するためです。オブジェクト指向プログラミングで広く使用されています。

まとめ

  • `Error`およびその他の組み込みエラークラスから正常に継承できます。`name`プロパティに注意し、`super`の呼び出しを忘れないでください。
  • `instanceof`を使用して、特定のエラーをチェックできます。継承でも機能します。ただし、サードパーティのライブラリからエラーオブジェクトが来る場合があり、そのクラスを取得する簡単な方法がない場合があります。その場合、`name`プロパティをそのようなチェックに使用できます。
  • 例外のラップは広く普及しているテクニックです。関数は低レベルの例外を処理し、さまざまな低レベルの例外ではなく、高レベルのエラーを作成します。上記の例では、低レベルの例外が`err.cause`のようなオブジェクトのプロパティになることがありますが、これは厳密には必須ではありません。

タスク

重要度:5

組み込みの`SyntaxError`クラスから継承するクラス`FormatError`を作成します。

`message`、`name`、`stack`プロパティをサポートする必要があります。

使用例

let err = new FormatError("formatting error");

alert( err.message ); // formatting error
alert( err.name ); // FormatError
alert( err.stack ); // stack

alert( err instanceof FormatError ); // true
alert( err instanceof SyntaxError ); // true (because inherits from SyntaxError)
class FormatError extends SyntaxError {
  constructor(message) {
    super(message);
    this.name = this.constructor.name;
  }
}

let err = new FormatError("formatting error");

alert( err.message ); // formatting error
alert( err.name ); // FormatError
alert( err.stack ); // stack

alert( err instanceof SyntaxError ); // true
チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善の提案がある場合は、コメントする代わりにGitHubのissueまたはプルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • 数語のコードを挿入するには、`<code>`タグを使用し、複数行の場合は`<pre>`タグで囲み、10行を超える場合はサンドボックス(plnkrjsbincodepen…)を使用してください。