LoginSignup
187
102

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-08-03

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

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

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

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

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

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

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

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

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

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

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

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

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 できることに起因しています)。

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 のエラーハンドリングに関する素晴らしい記事がいくつか見つかりました。

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

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

これらのライブラリを採用しても良いのですが、前述の「TypeScriptの異常系表現のいい感じの落とし所」にも記述があるようにライブラリへの依存度が高くなりすぎてしまうという問題があります。仮に採用したライブラリがメンテナンスされなくなってしまった場合、プロジェクトへの影響は深刻です。
また、fp-tslifts は TypeScript で関数型プログラミングを実現するためのライブラリなので、「エラーを型安全に扱いたい」という今回の目的に対して些か過剰です。

Result 型を作ってみる

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

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

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 型によりエラーハンドリングを次のように書くことができます。

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 型を使って型安全にエラーを扱うことができます。

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

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

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

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 として扱うようにします。

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

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 として扱うようにします。

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 については Errorthrow する仕様である方が好ましいでしょう。

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

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() を使って一時的に Errorthrow するようにしてしまえば Promise.all() で処理できるだろうというアイディアです。

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 が複雑になって書くのが面倒です。

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

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 で解決された場合は直ちにその Failureresolve することによって Failure が持つエラーの型情報を失うことなくフェイルファストの挙動を再現しています。

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

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

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

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

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

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

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

187
102
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
187
102