TypeScript のエラーハンドリングを考える

何番煎じかわかりませんが TypeScript でのエラーハンドリングについて考えてみたいと思います。

この記事で扱う TypeScript のバージョンは 4.3 です。

## エラーを型安全に扱いたい

TypeScript を書いていれば誰もが一度はぶつかる問題ではないでしょうか。

TypeScript では `catch` した例外は `any` として扱われます。
これは JavaScript の仕様上どんな値でも `throw` できてしまうため仕方のないことなのですが、せっかく型安全性を手に入れるために TypeScript を使っているのに `any` をハンドリングしなければならないのは苦痛です。

次のように例外を `throw` し得る関数 `foo()` のエラーハンドリングを考えてみます。

```typescript
try {
  // 例外を throw する処理
  foo();
} catch (e) {
  // ...
}
```

`e` は `any` なので、プロパティにアクセスしようにも危険性が伴います。
そこで型アノテーションを使用して `any` よりも型安全な `unknown` として扱うことにします。
(ちなみに TypeScript 4.4 から導入予定の `useUnknownInCatchVariables` 設定を使えば型アノテーションなしで `catch` した値を `unknown`　として扱うことができます)

```typescript
try {
  // 例外を throw する処理
  foo();
} catch (e: unknown) {
  // ...
}
```

これでタイプガードを使用することにより `e` のプロパティに安全にアクセスすることができます。

```typescript
try {
  // 例外を throw する処理
  foo();
} catch (e: unknown) {
  if (e instanceof Error) {
    console.error(e.message);
  }
}
```

上記のように単純なエラーハンドリングの場合は良いのですが、実際には `foo()` が `throw` する例外の種類に応じて適切な処理を行わなければならないことが多いでしょう。
TypeScript には Java の `throws` のように関数が `throw` する例外を宣言する方法がないので、どのような例外が `throw` され得るかを知るにはコードを読むしかありません。

```typescript
function foo() {
  // ErrorX, ErrorY を throw することを
  // ここから読み取らなければならない。
}

try {
  // 例外を throw する処理
  foo();
} catch (e: unknown) {
  if (e instanceof ErrorX) {
    // ErrorX のハンドリング
    // ...
  } else if (e instanceof ErrorY) {
    // ErrorY のハンドリング
    // ...
  } else {
    // ...
  }
}
```

`foo()` が複雑な関数の場合、`throw` され得る例外の型を読み取ることは難しくなります。

同様のことが Promise にも言えます。
`catch()` の引数の型は Promise のジェネリクスの型引数で指定することはできず、常に `any` として扱われます(これは Promise が `throw` によっても `reject` できることに起因しています)。

```typescript
function fooAsync(): Promise<void> {
  // ErrorX, ErrorY で reject することを
  // ここから読み取らなければならない。
}

fooAsync().catch((e: unknown) => {
  if (e instanceof ErrorX) {
    // ErrorX のハンドリング
    // ...
  } else if (e instanceof ErrorY) {
    // ErrorY のハンドリング
    // ...
  } else {
    // ...
  }
});
```

どうにかして関数が `throw` する例外の型や Promise が `reject` する値の型を TypeScript の型システムの中で表現したいところです。

## 型安全なエラーハンドリングのためのアプローチ

ではどうすれば良いのかを調べてみると TypeScript のエラーハンドリングに関する素晴らしい記事がいくつか見つかりました。

https://dev.classmethod.jp/articles/error-handling-practice-of-typescript/

https://labs.septeni.co.jp/entry/2020/07/23/100000

https://qiita.com/kabosu3d/items/680728362314f51bdcb0

詳しくはこれらの記事を実際に読んでいただきたいのですが、総合すると `Result` や `Either` のような正常系と異常系のどちらかを取るような型を導入し、例外を `throw` する代わりに `return` する方法が良さそうです。

また、このようなアプローチを実現するためのライブラリがいくつかあることがわかりました。

https://github.com/gcanti/fp-ts

https://github.com/supermacro/neverthrow

https://github.com/badrap/result

https://github.com/yarnaimo/lifts

これらのライブラリを採用しても良いのですが、前述の「[TypeScriptの異常系表現のいい感じの落とし所](https://dev.classmethod.jp/articles/error-handling-practice-of-typescript/)」にも記述があるようにライブラリへの依存度が高くなりすぎてしまうという問題があります。仮に採用したライブラリがメンテナンスされなくなってしまった場合、プロジェクトへの影響は深刻です。
また、[fp-ts](https://github.com/gcanti/fp-ts) や [lifts](https://github.com/yarnaimo/lifts) は TypeScript で関数型プログラミングを実現するためのライブラリなので、「エラーを型安全に扱いたい」という今回の目的に対して些か過剰です。

## Result 型を作ってみる

というわけで今回はこれらの記事やライブラリを参考に自分なりに `Result` 型を作ってみたいと思います。

`Result` 型は次のように定義してみました。

```typescript
type Result<T, E extends Error> = Success<T> | Failure<E>;

class Success<T> {
  readonly value: T;

  constructor(value: T) {
    this.value = value;
  }

  isSuccess(): this is Success<T> {
    return true;
  }

  isFailure(): this is Failure<Error> {
    return false;
  }
}

class Failure<E extends Error> {
  readonly error: E;

  constructor(error: E) {
    this.error = error;
  }

  isSuccess(): this is Success<unknown> {
    return false;
  }

  isFailure(): this is Failure<E> {
    return true;
  }
}
```

`Result` は正常系を意味するクラスの `Success` と異常系を意味するクラスの `Failure` のいずれかの値を取る `Union` として定義しています。

この `Result` 型によりエラーハンドリングを次のように書くことができます。

```typescript
function foo(): Result<string, ErrorX | ErrorY> {
  // 正常系
  // Success を返す。
  return new Success("success");

  // 異常系
  // throw は使わずに Failure を返す。
  return new Failure(new ErrorX("x"));
}

(() => {
  const result = foo();
  if (result.isFailure()) {
    // 異常系の処理
    // 型推論により err が ErrorX または ErrorY であることがわかる。
    const err = result.error;
    if (err instanceof ErrorX) {
      // ErrorX のハンドリング
      // ...
      return;
    }
    // ErrorY のハンドリング
    // ...
    return;
  }

  // 正常系の処理
  console.log(result.value);
})();
```

`Result` を使うことでエラーを型安全に扱えるだけでなく、呼び出し元でのエラーハンドリングを強制することができています。

## Promise (async/await) と Result

Promise の場合も `Result` 型を使って型安全にエラーを扱うことができます。

扱いやすさのため、次のような型を定義しました。

```typescript
type PromiseResult<T, E extends Error> = Promise<Result<T, E>>;
```

次のように書くことができます。

```typescript
function fooAsync(): PromiseResult<string, ErrorX | ErrorY> {
  return new Promise<Result<string, ErrorX | ErrorY>>((resolve) => {
    // 正常系
    // Success で resolve する。
    resolve(new Success("success"));

    // 異常系
    // throw や reject は使わずに Failure で resolve する。
    resolve(new Failure(new ErrorX("x")));
  });
}

(async () => {
  const result = await fooAsync();
  if (result.isFailure()) {
    // 異常系の処理
    // 型推論により err が ErrorX または ErrorY であることがわかる。
    const err = result.error;
    if (err instanceof ErrorX) {
      // ErrorX のハンドリング
      // ...
      return;
    }
    // ErrorY のハンドリング
    // ...
    return;
  }

  // 正常系の処理
  console.log(result.value);
})();
```

## Result の Tips

### 例外を throw する関数を扱う

標準ライブラリや外部ライブラリの関数を呼び出す場合は、`try...catch` で適切に処理して `Result` として扱うようにします。

次のような関数があると便利です。

```typescript
function tryCatch<T, E extends Error>(
  func: () => T,
  // 発生する例外は any なので適切な型に変換するための
  // 関数を与える。
  onCatch: (e: unknown) => E
): Result<T, E> {
  try {
    const value = func();
    return new Success<T>(value);
  } catch (err) {
    return new Failure<E>(onCatch(err));
  }
}
```

標準ライブラリや外部ライブラリの Promise も同様に `PromiseResult` として扱うようにします。

```typescript
async function tryCatchAsync<T, E extends Error>(
  func: () => Promise<T>,
  // 発生する例外は any なので適切な型に変換するための
  // 関数を与える。
  onCatch: (e: unknown) => E
): PromiseResult<T, E> {
  try {
    const value = await func();
    return new Success<T>(value);
  } catch (err) {
    return new Failure<E>(onCatch(err));
  }
}
```

### Result から例外を取り出す

ライブラリを作る場合、内部処理でのエラーハンドリングは `Result` で行うにしても外部に提供する API については `Error` を `throw` する仕様である方が好ましいでしょう。

次のように `Result` が内包する値を取り出すための関数があると重宝します。

```typescript
function unwrap<T, E extends Error>(result: Result<T, E>): T {
  if (result.isFailure()) {
    throw result.error;
  }
  return result.value;
}
```

### Promise.all() を使う

`Promise.all()` を使いたい場合は注意が必要です。

`PromiseResult` を使う場合、 Promise は正常系であろうと異常系であろうと常に `fulfilled` となります。
つまり `PromiseResult` を使っている Promise を何も考えずに `Promise.all()` で使ってしまうと、`Promise.all()` のフェイルファストの挙動を実現できません(`Promise.allSettled()` のような挙動になります)。

`PromiseResult` でフェイルファストを実現するには、全ての Promise が `Success<T>` を返したら `Success<T[]>`、1 つでも `Failure<E>` を返したら `Failure<E>` を返すような関数が必要です。

この問題を解決するため、最初は次のような関数を定義してみました。
前述の `unwrap()` を使って一時的に `Error` を `throw` するようにしてしまえば `Promise.all()` で処理できるだろうというアイディアです。

```typescript
function promiseAll<T, E extends Error>(
  // 本来の Promise.all() は PromiseLike や非 Promise な値も
  // 受け付けますが簡単のために PromiseResult の配列に限定しています。
  values: readonly PromiseResult<T, E>[],
  onCatch: (e: unknown) => E
): PromiseResult<T[], E> {
  return tryCatchAsync(() => {
    return Promise.all(values.map(async (v) => unwrap(await v)));
  }, onCatch);
}
```

ただ実際に使ってみるとこの関数は失敗であることがわかりました。

`unwrap()` することで `Failure` が持っていたエラーの型情報が失われてしまうため、`catch` した値を元の型に戻すための `onCatch` が必要となってしまいます。
元々正しい型を持っていたのに再度変換するための関数を書くのは冗長ですし、複数種類のエラーを扱うケースでは `onCatch` が複雑になって書くのが面倒です。

そこで次のような形に修正しました。

```typescript
function promiseAll<T, E extends Error>(
  // 本来の Promise.all() は PromiseLike や非 Promise な値も
  // 受け付けますが簡単のために PromiseResult の配列に限定しています。
  values: readonly PromiseResult<T, E>[]
): PromiseResult<T[], E> {
  return new Promise<Result<T[], E>>((resolve, reject) => {
    const results: T[] = [];
    for (const v of values) {
      v.then((result) => {
        if (result.isFailure()) {
          resolve(result);
          return;
        }
        results.push(result.value);

        if (results.length === values.length) {
          resolve(new Success(results));
        }
      }).catch(reject);
    }
  });
}
```

`Promise.all()` を利用することを諦めて、ほぼ同等の処理を自前で書いています。
与えられた　Promise のいずれかが `Failure` で解決された場合は直ちにその `Failure` で `resolve` することによって `Failure` が持つエラーの型情報を失うことなくフェイルファストの挙動を再現しています。

## 型安全なエラーの弊害と用法

`Result` のような型を導入してエラーを型安全に扱うことにはメリットもありますが、注意して扱わないとかえってデメリットをもたらすこともあります。

このデメリットとは Java の検査例外についてよく言われるものとほぼ同様のものです。
雑に説明してしまうと次のようなものがあります。

- バックエンドの変更などにより関数が返すエラーの型を変更するとコードの互換性が損なわれてしまう
    - いわゆる「オープン・クローズドの原則」に違反する
- インターフェースや抽象クラスで指定したエラーの型が具象クラスで発生するエラーの型を制限してしまう
- 関数が返すエラーの型が増えすぎてハンドリングしきれなくなる

これらの問題は次のように適切な設計をすることである程度回避することができます。

- 下位レイヤーのエラーをそのまま上位レイヤーに持ち出さない
    - レイヤーに合わせて適切にエラーを抽象化する
- 呼び出し側でハンドリングできない (すべきでない) エラーを返さない
    - ハンドリングできないエラーは `Result` にせず `throw` するなど

つまり型安全にエラーを扱おうとすると必然的にエラー設計のコストが大きくなります。
エラーを型安全にするメリットがそのコストに見合うかどうかについてはよく考える必要がありそうです。