📝

JavascriptでErrorオブジェクトをJSON.stringify()でシリアライズする方法について整理してみた。

2024/03/05に公開

JavascriptのErrorオブジェクトをJSON.stringify()に入れるとプロパティが抜け落ちてしまう

エラーが起きたときの情報やスタックトレースをログなどに何らかの形で保存することがたまにあると思います。JavascriptではErrorオブジェクト[1]をそのままJSON.stringify()[2]に渡しても空のオブジェクトを示す文字列が出力されてしまいます。このことは下記のコードで確認できます。

const error = new Error('Hello, Error!')
console.log(error.name) // Error
console.log(error.message) // Hello, Error!
console.log(JSON.stringify(error)) // {}

今回はこの問題への対処法についてまとめてみました。

なぜプロパティが抜け落ちてしまうか

Javascriptのオブジェクトのプロパティには列挙可能フラグという内部的な設定[3]があります。このフラグがオフである、つまり列挙不可能なプロパティをJSON.stringify()はシリアライズしません。Errorオブジェクトのプロパティはすべて列挙不可能なプロパティのため、JSON.stringify()に適用してもシリアライズされません。各プロパティが列挙可能かどうかはObject.prototype.propertyIsEnumerable()を使うと確認できます。
下記のコードではErrorオブジェクトのプロパティと、Errorオブジェクトのようなプロパティをもったただのオブジェクトの列挙可能性とJSON.stringify()を実行した結果を比較しています。

const error = new Error('Hello, Error!');
console.log(error.propertyIsEnumerable('name'));     // false
console.log(error.propertyIsEnumerable('message'));  // false
console.log(error.propertyIsEnumerable('stack'));    // false
console.log(JSON.stringify(error)); // {}

const errorLike = { name: 'ErrorLike', message: 'Hello ErrorLike', stack: 'stack' };
console.log(errorLike.propertyIsEnumerable('name'));    // true
console.log(errorLike.propertyIsEnumerable('message')); // true
console.log(errorLike.propertyIsEnumerable('stack'));   // true
console.log(JSON.stringify(errorLike)); // {"name":"ErrorLike","message":"Hello ErrorLike","stack":"stack"}

列挙不可能なプロパティはほかに、Object.keys()の対象にならない、スプレッド構文で展開されない、for...inの対象にならない、などの動作をします。

const error = new Error('Hello, Error!');
console.log(Object.keys(error)); // []
console.log({...error});         // {}
for (const key in error) {
  console.log(key);
} // 何も表示されない

const errorLike = { name: 'ErrorLike', message: 'Hello ErrorLike', stack: 'stack' };
console.log(Object.keys(errorLike)); // ['name', 'message', 'stack']
console.log({...errorLike});         // {name: 'ErrorLike', message: 'Hello ErrorLike', stack: 'stack'}
for (const key in error) {
  console.log(key);
} // name, message, stack が順に表示

どうやってJSON.stringify()のシリアライズ対象にするか?

https://stackoverflow.com/questions/18391212/is-it-not-possible-to-stringify-an-error-using-json-stringify
StackOverflowの上記の質問にはこの問題に対する様々な回答が寄せられています。この回答の中からよさそうな方法を列挙して比較していきます。

1. カスタムエラーにtoJSON()を実装

JSON.stringify()は対象となるオブジェクトがtoJSON()を持っている場合はtoJSON()を呼び出した結果をシリアライズします。このため、アプリケーションでカスタムエラーを使用している場合はtoJSON()を実装するという方法があります。

下記はtoJSON()を実装したカスタムエラーの例です。内部のプロパティを全て列挙してただのオブジェクトに詰め直して返却しています。詰め直した後のオブジェクトのプロパティは全て列挙可能なのでJSON.stringify()はこれらをシリアライズします。

class AppError extends Error {
  static {
    this.prototype.name = "AppError";
  }
  toJSON() {
    const result = {};
    Object.getOwnPropertyNames(this).forEach((key) => {
      result[key] = this[key];
    });
    return result;
  }
}

const error = new AppError("Hello, Error!");
console.log(JSON.stringify(error, null, 2));
// {
//   "stack": "AppError: Hello, Error!\n    at <anonymous>:14:15",
//   "message": "Hello, Error!"
// }

アプリケーション開発においてErrorを継承して個別のエラー状況に応じたカスタムエラーを作成することは一般的な方法です。このためカスタムエラー全てにtoJSON()を実装する方法は自然な解決策だと思います。
後述する他の方法に対してこの方法はアプリケーション内で多様なカスタムエラーを実装している場合にエラー毎のシリアライズ方法を記述しやすいのがメリットです。

この方法の欠点は現在の実装でErrorを直接使っている場合には広範囲の修正が必要になることと、外部のライブラリが投げるエラーがtoJSON()を実装していない場合に対処できないことが挙げられます。

2. Error.prototype.toJSONに関数を追加

前項の欠点を克服するのがError.prototype.toJSONに置換用の関数を入れる方法です。この方法を使うとプログラム中の全てのErrortoJSON()を設置することができます。プログラムの最初にこの処理の追加をするだけで他の箇所に全く手を入れずに全てのエラーをシリアライズできるようになります。もちろん外部のライブラリの中から投げられたエラーも通常はErrorを継承しているのでシリアライズできるようになります。

この方法の問題点は全てErrorにこの処理が適用されてしまうことです。素のErrorを前提とした外部ライブラリや、Error.prototype.toJSONを置き換える外部ライブラリを利用した場合などにトラブルを引き起こしかねません。

Error.prototype.toJSON = function() {
  const result = {};
  Object.getOwnPropertyNames(this).forEach((key) => {
    result[key] = this[key];
  });
  return result;
}
const error = new Error('Hello, Error!');
console.log(JSON.stringify(error));
// {
//   "stack": "Error: Hello, Error!\n    at <anonymous>:8:15",
//   "message": "Hello, Error!"
// }

3. JSON.stringify()の第二引数を使って置き換

前項のように実行中のプログラムの全てのErrorへの影響を嫌う場合、JSON.stringify()の第二引数を利用する方法が良いです。JSON.stringify()の第二引数にはシリアライズ時の各プロパティを置換するための関数を入れることができます。

const replaceErrors = (_key, value) => {
  if (value instanceof Error) {
    const error = {};
    Object.getOwnPropertyNames(value).forEach((name) => {
      error[name] = value[name];
    });
    return error;
  }

  return value;
};

const error = new Error("Hello, Error!");
console.log(JSON.stringify(error, replaceErrors));
// {
//   "stack": "Error: Hello, Error!\n    at <anonymous>:13:15",
//   "message": "Hello, Error!"
// }

結局どうすれば良いか?

これら3つの方法の特徴を表に整理しました。

Head 個別のエラー特有の情報処理 外部ライブラリが投げるエラー 安全性
カスタムエラーにtoJSON()を実装 ×
Error.prototype.toJSONに関数を追加
JSON.stringify()の第二引数を使って置き換え

カスタムエラーを使っている場合、個別の特有の情報処理はやはりそのカスタムエラー自身にtoJSON()を実装することが望ましいでしょう。他の2つの方法を利用する場合、処理用の関数の中でinstanceOfを使って分岐して処理を記述する必要があります。
外部のライブラリが投げる素のErrorオブジェクトもシリアライズしたい場合、自分たちで作成したカスタムエラーは使えないのでError.prototype.toJSONに関数を追加JSON.stringify()の第二引数を使って置き換えのどちらかを選ぶ必要があります。しかし前者に関しては全てのErrorオブジェクトに影響が出てしまうため、利用している外部のライブラリによって予期せぬ挙動をしてしまう可能性があります。
これらを踏まえると、基本的にはJSON.stringify()の第二引数を使って置き換えを採用して、必要に応じてカスタムエラーにtoJSON()を実装するのが良いのではないかと思います。

余談: AxiosErrorのtoJSONは要注意。

著名なHTTPクライアントのaxios[4]が投げるエラーのAxiosErrorは標準でtoJSON()を備えています。このため特に意識しなくてもJSON.stringify()でシリアライズされます。また、失敗したHTTPリクエストに関する情報の詳細もシリアライズされるため便利です。しかし、詳細に残っているのでリクエストのAuthorizationヘッダーに入っている認証情報もシリアライズされてしまいます。AxiosErrorをJSON.stringify()でシリアライズしてログに残す際はご注意ください。

余談: エラーのシリアライズ時はエラーのネストの考慮が必要

最近のJavascriptのErrorにはエラーの原因となった情報を保存するcauseプロパティ[5]が存在します。このプロパティにはエラーに関する情報や、エラーの元となったエラーが保存されることが想定されています。後者のようにエラーがcauseに保存される場合はエラーのネストが発生するため、シリアライズ方法を検討する際はこのcauseの考慮が必要になります。今回紹介した方法はどれもcauseを利用した場合でもシリアライズが可能です。

const original = new Error("Original Error")
const error = new Error("Hello, Error!", { cause: original });
const data = { tags: ["replace-errors"], error: error };
console.log(JSON.stringify(data, replaceErrors, 2));
);
// {
//   "tags": [
//     "replace-errors"
//   ],
//   "error": {
//     "stack": "Error: Hello, Error!\n    at <anonymous>:2:15",
//     "message": "Hello, Error!",
//     "cause": {
//       "stack": "Error: Original Error\n    at <anonymous>:1:18",
//       "message": "Original Error"
//     }
//   }
// }
脚注
  1. https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Error ↩︎

  2. https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify ↩︎

  3. https://developer.mozilla.org/ja/docs/Web/JavaScript/Enumerability_and_ownership_of_properties ↩︎

  4. https://github.com/axios/axios ↩︎

  5. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause ↩︎

コミューン株式会社

Discussion