JavaScript でのエラー抽象化とエラーチェイン

## なぜエラーの抽象化が必要なのか

例として、あるアプリケーションにローカルファイルへの操作を抽象化した次のような `Storage` クラスがあったとします。

```javascript
import fs from "fs/promises";

export class Storage {
  async get(key) {
    const buf = await fs.readFile(key, {
      encoding: "utf-8",
    });
    return buf.toString();
  }
}
```

`get()` メソッドにファイルパスを与えると、ファイルの内容を文字列として返します。

この `Storage` クラスを利用するコードの一例として、次のような `getConfig()` 関数があったとします。

```javascript
async function getConfig(storage) {
  try {
    const config = await storage.get("config.json");
    return JSON.parse(config);
  } catch (e) {
    if (e.code === "ENOENT") {
      return {};
    }
    throw e;
  }
}
```

`getConfig()` はローカルの `config.json` を取得し、オブジェクトとして返す関数です。
もし `config.json` が存在しなかった場合、空のオブジェクトを返す仕様となっています。

さて、ある時このアプリケーションの要件が変わり、ローカルのファイルシステムをストレージとして利用するのをやめて S3 を使わなければならなくなったとします。

ストレージを抽象化している `Storage` クラスを変更すれば対応できそうです。

```javascript
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

export class Storage {
  #bucket;
  #s3Client;

  constructor(bucket, config) {
    this.#bucket = bucket;
    this.#s3Client = new S3Client(config);
  }

  async get(key) {
    const command = new GetObjectCommand({
      Bucket: this.#bucket,
      Key: key,
    });
    const res = await this.#s3Client.send(command);

    let out = "";
    for await (const chunk of res.Body) {
      out += chunk.toString();
    }
    return out;
  }
}
```

`get()` メソッドのインタフェースを変更せずにストレージを S3 に差し替えることができました。
コンストラクタは変更されているのでアプリケーションの初期化部分のコードは修正が必要ですが、大部分のコードは S3 対応の影響を受けずに動作するはずです。
このように抽象化をうまく用いることで変更に強いコードを書くことができます。

しかし前述の `getConfig()` の場合はそうはいきません。
`config.json` の取得元が変わったことにより、ファイルが存在しなかった場合に発生するエラーも変わってしまっているためです。

仕様通りに動作させるためには次のような変更が必要です。

```javascript
async function getConfig(storage) {
  try {
    const config = await storage.get("config.json");
    return JSON.parse(config);
  } catch (e) {
    if (e.name === "NoSuchKey") {
      return {};
    }
    throw e;
  }
}
```

結局、`get()` メソッドの呼び出し元で `ENOENT` のような SystemError をハンドリングしているコードはすべて変更する必要が出てしまいました。

何がまずかったのでしょうか。
それは `Storage` クラスがエラーを抽象化せず、下位レイヤーのエラーをそのまま上位レイヤーに投げてしまう設計になっていた点です。

より変更に強いコードにするには、呼び出し元でハンドリングされ得るエラーを適切に抽象化してあげる必要があります。

今回のケースでは「ファイルが存在しない場合のエラー」を抽象化するように `Storage` クラスを設計すべきでした。

```javascript
import fs from "fs/promises";

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

export class Storage {
  async get(key) {
    try {
      const buf = await fs.readFile(key, {
        encoding: "utf-8",
      });
      return buf.toString();
    } catch (e) {
      if (e.code === "ENOENT") {
        throw new NotFoundError();
      }
      throw e;
    }
  }
}
```

このような実装になっていれば `getConfig()` では抽象化した `NotFoundError` のみを扱えばよくなるので下位レイヤーのエラーを直接ハンドリングする必要がなくなります。

```javascript
async function getConfig(storage) {
  try {
    const config = await storage.get("config.json");
    return JSON.parse(config);
  } catch (e) {
    if (e instanceof NotFoundError) {
      return {};
    }
    throw e;
  }
}
```

ストレージを S3 に差し替えても `NotFoundError` を throw するように実装すれば `getConfig()` に影響は出ません。

```javascript
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

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

export class Storage {
  #bucket;
  #s3Client;

  constructor(bucket, config) {
    this.#bucket = bucket;
    this.#s3Client = new S3Client(config);
  }

  async get(key) {
    const command = new GetObjectCommand({
      Bucket: this.#bucket,
      Key: key,
    });

    try {
      const res = await this.#s3Client.send(command);

      let out = "";
      for await (const chunk of res.Body) {
        out += chunk.toString();
      }
      return out;
    } catch (e) {
      if (e.name === "NoSuchKey") {
        throw new NotFoundError();
      }
      throw e;
    }
  }
}
```

## 元のエラー情報を失わずに抽象化する

上記の `NotFoundError` を用いたコードにも問題があります。
それはエラーを抽象化する際に元のエラーの情報が失われてしまっているという点です。

```javascript
  async get(key) {
    try {
      const buf = await fs.readFile(key, {
        encoding: "utf-8",
      });
      return buf.toString();
    } catch (e) {
      if (e.code === "ENOENT") {
        // 全く新しい Error を生成しているので元の e が失われている。
        throw new NotFoundError();
      }
      throw e;
    }
  }
```

予期せぬエラーが発生した際のログ出力やデバッグのことを考えると、エラーを抽象化する際にその原因となったエラーの情報は保持したまま throw したいところです。

つまり、エラーを抽象化する際に元のエラーを wrap して繋いでいくような仕組み (エラーチェイン) が必要です。

## Error Cause を使おう

Error Cause はエラーチェインを標準化する ECMAScript の仕様で、2021 年 10 月 26 日に Stage 4 に移行 (ES2022 で標準化されることが決定) しました。

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

この仕様では

- Error のコンストラクタの第二引数にオプションとして原因となったエラー `cause` を与えられること
- Error インスタンスの `cause` プロパティで原因となったエラーを取得できること

を規定しています。

```javascript
const err1 = new Error("error 1");
const err2 = new Error("error 2", { cause: err1 });
const err3 = new Error("error 3", { cause: err2 });

console.error(err3);
console.error(err3.cause); // err2 が出力される。
console.error(err3.cause.cause); // err3 が出力される。
```

仕様としてはこれだけです。

これだけの仕様なら Error を継承したカスタムエラーで再現することは容易です。
また、[verror](https://github.com/joyent/node-verror) のようにエラーチェインを実現するためのライブラリもいくつか存在しています。

ではなぜあえて Error Cause を標準化する必要があったのでしょうか。
それは `cause` プロパティを標準仕様として規定することにより、デバッグツールなどでエラーチェインを扱えるようにするためです。

例として [jest](https://github.com/facebook/jest) では Error Cause の標準化を受けてテスト中に発生したエラーのエラーチェインを辿ってすべての stack trace を出力する機能が提案され、前向きに検討されています。

https://github.com/facebook/jest/issues/11935

今後は Error Cause を使っていく方が様々なツールの恩恵を受けやすくなっていくものと思われます。

そんな Error Cause ですが、Node.js では [v16.9.0](https://nodejs.org/en/blog/release/v16.9.0/#error-cause) から使用可能になっています。
また、モダンブラウザの一部でも利用可能です。

https://caniuse.com/mdn-javascript_builtins_error_error_options_cause_parameter

https://caniuse.com/mdn-javascript_builtins_error_cause

Node.js v12, v14 や非対応のブラウザでは [error-cause](https://www.npmjs.com/package/error-cause) や [Pony Cause](https://github.com/voxpelli/pony-cause) といった polyfill を使用すると良いでしょう。
Error Cause において重要なのは `cause` プロパティなので、あるいは `cause` プロパティを持ったカスタムエラーを作っても同様の恩恵が得られるはずです。

## チェインしたエラーも含めた stack trace を出力する

Error Cause でチェインしたエラーをコンソール出力しても `cause` の情報は何も出力されません(`stack` プロパティも同様)。

```javascript
const err1 = new Error("error 1");
const err2 = new Error("error 2", { cause: err1 });
const err3 = new Error("error 3", { cause: err2 });

console.error(err3); // err2, err1 の情報は出力されない。
console.error(err3.stack); // err2, err1 の stack は出力されない。
```

実際の利用を考えると、エラーチェインを辿って stack trace を生成するような関数を用意してあげると良いでしょう[^1]。
[^1]: [Pony Cause には定義されています](https://github.com/voxpelli/pony-cause#stackwithcauses-getting-full-stack-trace-for-error--all-causes)
TypeScript で実装すると次のような感じでしょうか。

```typescript
export function stackWithCauses(err: unknown): string {
  return stackWithCausesRecursively(err, new Set());
}

function stackWithCausesRecursively(err: unknown, seen: Set<Error>): string {
  if (err instanceof Error) {
    const stack = err.stack ? err.stack : err.toString();

    if (seen.has(err)) {
      return `${stack}\n... (truncated because the causes have become circular)`;
    }

    if (isErrorWithCause(err)) {
      const cause = err.cause;
      if (cause) {
        seen.add(err);
        return `${stack}\nCaused by ${stackWithCausesRecursively(cause, seen)}`;
      }
    }

    return stack;
  }

  return "";
}

interface ErrorWithCause extends Error {
  cause: unknown;
}

function isErrorWithCause(err: unknown): err is ErrorWithCause {
  return err instanceof Error && "cause" in err;
}
```

コンソール出力するときは次のようになります。

```javascript
const err1 = new Error("error 1");
const err2 = new Error("error 2", { cause: err1 });
const err3 = new Error("error 3", { cause: err2 });

console.log(stackWithCauses(err3));
```

### Node.js での小技

何も考えずに `console.error(e)` でいい感じに `cause` の情報も出力したい！という場合、Node.js ならカスタムエラーに [util.inspect.custom](https://nodejs.org/api/util.html#custom-inspection-functions-on-objects) シンボルのメソッドを定義してあげれば実現可能です。

```typescript
import util, { InspectOptions } from "util";

interface InspectOptionsWithSeen extends InspectOptions {
  // あまり良い方法とは思えませんが、
  // cause の循環をチェックするため InspectOptions に
  // 参照済みの Error を記録します。
  _seen?: Set<Error>;
}

export class CustomError extends Error {
  constructor(message?: string, options?: { cause?: unknown }) {
    super(message, options);
  }

  [util.inspect.custom](_depth: number, options: InspectOptionsWithSeen) {
    const noCustomInspect: InspectOptions = Object.assign({}, options, {
      customInspect: false,
    });
    const original = util.inspect(this, noCustomInspect);

    if (options._seen?.has(this) ?? false) {
      return `${original}\n... (truncated because the causes have become circular)`;
    }

    if (!this.cause) {
      return original;
    }

    if (!options._seen) {
      options._seen = new Set();
    }
    options._seen.add(this);

    return `${original}\nCaused by ${util.inspect(this.cause, options)}`;
  }
}
```