🚨

エラーをチェインするライブラリと ES2022 Error Cause

2021/10/02に公開
追記情報

【2023/04/29 追記】

各エンジンのコンソールへへの表示について、古くなっている記述を修正。

【2021/10/27 追記】

2021年10月の TC39 meeting で Stage 4 になったため記事のタイトル、一部内容を更新しています。

エラーを識別するモチベーション

デバッグを容易にするためにエラーを識別したいことがあります。例えば Fetch API を二回実行した際にエラーが投げられてしまうことを考えてみます。

async function fetchData() {
  // データを取得するために Cookie にセッションを登録
  await fetch("/api/auth");

  // データを取得する
  const response = await fetch("/api/data");
  return await response.json();
}

let data;
try {
  data = await fetchData();
} catch (e) {
  // TypeError: Failed to fetch
  //   at fetch
  console.error(e);
}

このときにどの Fetch API からエラーが投げられたのかわかりにくい問題があります。かといって以下のように握りつぶしてしまうと肝心のエラーの中身がわからなくなってしまいます。

async function fetchData() {
  // データを取得するために Cookie にセッションを登録
  try {
    await fetch("/api/auth");
  } catch {
    throw new Error("Failed to auth");
  }

  // データを取得する
  try {
    const response = await fetch("/api/data");
    return await response.json();
  } catch {
    throw new Error("Failed data fetch");
  }
}

let data;
try {
  data = await fetchData();
} catch (e) {
  // Error: Failed to auth
  //   at fetchData
  console.error(e);
}

エラーをチェインするライブラリ

エラーオブジェクトをラップして新たなエラーオブジェクトを作ることが出来るライブラリがあります。これらを使うことでエラーの識別が容易になります。

extensible-custom-error

カスタムエラーを作ることが出来るライブラリです。コンストラクタの第二引数にエラーオブジェクトを入れることで stack プロパティを上書きし、内部エラーとマージさせます。それによって通常の実装においてコンソール上で内部エラーのスタックトレースも見ることが出来ます。

import ExtensibleCustomError from "extensible-custom-error";
class CustomError extends ExtensibleCustomError {}

async function fetchData() {
  // データを取得するために Cookie にセッションを登録
  try {
    await fetch("/api/auth");
  } catch (e) {
    throw new CustomError("Failed to auth", e);
  }

  // データを取得する
  try {
    const response = await fetch("/api/data");
    return await response.json();
  } catch (e) {
    throw new CustomError("Failed data fetch", e);
  }
}

let data;
try {
  data = await fetchData();
} catch (e) {
  // CustomError: Failed to auth
  //   at fetchData
  // TypeError: Failed to fetch
  //   at fetch
  console.error(e);
}

https://github.com/necojackarc/extensible-custom-error

TraceError.js

TraceError クラスを提供するライブラリです。第二引数にエラーオブジェクトを入れることで内部エラーを保持することができます。こちらも stack プロパティを上書きするため、コンソールで見やすくなります。

import TraceError from 'trace-error';

async function fetchData() {
  // データを取得するために Cookie にセッションを登録
  try {
    await fetch("/api/auth");
  } catch (e) {
    throw new TraceError("Failed to auth", e);
  }

  // データを取得する
  try {
    const response = await fetch("/api/data");
    return await response.json();
  } catch (e) {
    throw new TraceError("Failed data fetch", e);
  }
}

let data;
try {
  data = await fetchData();
} catch (e) {
  // TraceError: Failed to auth
  //   at fetchData
  // TypeError: Failed to fetch
  //   at fetch
  console.error(e);
}

https://github.com/mathew-kurian/TraceError.js

ES2022 Error Cause

仕様で定義されているエラーオブジェクトのコンストラクタにオプションとして内部エラーを受け取れる拡張を行うのが ES2022 Error Cause です。前述したライブラリとは異なりこの仕様では stack プロパティについては特に言及せず、単に cause プロパティによって内部エラーにアクセス出来るというものとなっています。

async function fetchData() {
  // データを取得するために Cookie にセッションを登録
  try {
    await fetch("/api/auth");
  } catch (e) {
    throw new Error("Failed to auth", { cause: e });
  }

  // データを取得する
  try {
    const response = await fetch("/api/data");
    return await response.json();
  } catch (e) {
    throw new Error("Failed data fetch", { cause: e });
  }
}

let data;
try {
  data = await fetchData();
} catch (e) {
  // Error: Failed to auth
  //   at fetchData
  console.error(e);
  // TypeError: Failed to fetch
  //   at fetch
  console.error(e.cause);
}

https://github.com/tc39/proposal-error-cause

この提案はコンストラクタを拡張するものではありますが、実質的にエラーオブジェクトに cause プロパティを与えているのと変わりはありません。どちらかといえばエラーオブジェクトは内部エラーを cause プロパティとして持つものとして定めたという側面が強いです。

try {
  // some code
} catch (e) {
  // 実質同じ
  const error = new Error("Foo Error");
  error.cause = e;
  throw error;
}

実装環境にもよりますが、今のところコンソール上で内部エラー情報を表示したい場合は明示的に cause プロパティにアクセスする必要があります。コンソールへの表示内容は実装依存[1]なので規定できませんが、これは将来的に各実装が対応することによって解決されるものかと思われます。

2023年04月現在、ブラウザの中では Firefox のみ cause プロパティにアクセスする必要なく内部エラーの情報も表示してくれます。

Firefox 92.0.1 console

Node.js では v16.9.0 からコンストラクタのオプションへの対応が入り、v16.14.0 からコンソールに表示されるようになりました。

https://nodejs.org/en/blog/release/v16.9.0/#error-cause

また Deno では v1.13.0 からコンストラクタのオプションへの対応が入り、v1.18.0 からコンソールに表示されます。

https://zenn.dev/magurotuna/articles/deno-release-note-1-13-0#error-cause

https://github.com/denoland/deno/issues/12308

何故仕様で stack の挙動に言及しないのか

実はスタックトレース及びエラーオブジェクトの stack プロパティは各エンジンによる独自実装です。ECMAScript には含まれていません。互換性の問題により仕様で stack プロパティの挙動について規定することができず、単に cause プロパティを追加するだけの提案になったわけです。

スタックトレースが独自実装なのはそれはそれで問題なため、仕様側でちゃんと規定する提案が Stage 1 Error Stacks です。基本的にはエラーオブジェクトを受け取ってスタックトレースを返す函数を追加する提案となっており、サブとして Web Reality に合うように Annex B に Error#stack を追加するものとなっています。

https://github.com/tc39/proposal-error-stacks

Annex B についての詳細は別の記事があるのでこちらを御覧ください。

https://zenn.dev/petamoriken/articles/a211183011cd58

余談ですが V8 は非標準の Stack trace API を持っています。もちろんこれは Chromium 系ブラウザや Node.js そして Deno でのみ扱えます。

https://v8.dev/docs/stack-trace-api

内部エラーをマージしたスタックトレース文字列を生成する

Error Cause の ponyfill である Pony Cause が提供する stackWithCauses 函数のように、cause プロパティを辿りスタックトレースを組み立てるコードが必要になります。

https://github.com/voxpelli/pony-cause#stackwithcauses-getting-full-stack-trace-for-error--all-causes

素朴に実装してみると以下のようになるかなと思います。

function stackWithCause(error: Error): string {
  const causes: Error[] = [];

  let temp = error;
  while (
    temp.cause instanceof Error &&
    temp.cause !== error && !causes.includes(temp.cause) // circular check
  ) {
    causes.push(temp.cause);
    temp = temp.cause;
  }

  return `${error.stack}${
    causes.map((cause) => `\nCaused by ${cause.stack}`).join("")
  }`;
}

もちろん現状非標準である stack プロパティに依存してしまうのでうまく動作しなくなってしまう可能性はあります。しかしその非標準の上に成り立っているスタックトレースを操作するライブラリで溢れている現代において、破壊的な変更はそうそう起きないだろうと思うので基本的には大丈夫でしょう[2]

それはそうとして言語仕様側で持っていて欲しい機能ではあるので、時期尚早ですが将来のために issue をたてました。

https://github.com/tc39/proposal-error-stacks/issues/41

Sentry での扱い

エラーを監視する Sentry の Official JavaScript SDK である sentry-javascript では、デフォルトのインテグレーション LinkedErrors によって既にエラーオブジェクトが内部エラーを cause プロパティとして持つものとして独自に扱っています。つまり特に設定する必要なく内部エラーもログに残すことが出来ます。

https://github.com/getsentry/sentry-javascript/issues/1401

結び

今回は Error Cause を紹介してみました。スタックトレースが実装依存なため少し扱いにくいところはありますが、言語仕様に内部エラーを保持する方法が提供されるのは便利なのかと思います。

ECMAScript の提案を追うのは割と楽しいので皆さんもよかったらどうぞ。

脚注
  1. Console API は WHATWG の Console Standard で定義されています。表示内容が実装依存なのはここに記されています。 ↩︎

  2. 今のところ ES2022 Error Cause を実装した JavaScript エンジンにおいて、stack プロパティに内部エラーのスタックトレースをマージさせるような実装はされていません。もしされてしまった場合この方法だとダブルプリンティングが発生してしまいますがきっと大丈夫でしょう。 ↩︎

Discussion