TypeScript エラーハンドリング

関数でエラーが発生したとき

  • エラーの発生の対応で throw するか return で返すか迷うことがある
  • throw には簡潔さ、return には明瞭さと型安全性といった特徴がある
  • どちらがより適しているかはプログラムの規模、エラーの種類の多さ、ハンドリングの方法などでまよう。
  • フレームワークやプロジェクトのコーディング規約などをもとに実装するが、関数から呼び出してエラーを伝える方法は throw するか return するかまよいます。

throw

エラーを throw して呼び出し元に伝える方法
Promise の reject は async 関数内の throw と同じなので throw するにしてます。

throw new Error("...")
Promise.reject(new Error("..."))

return

エラーも戻り値のひとつとして、return で呼び出し元に伝える方法です。

T | undefined
{ success: true; value: T } | { success: false; error: E }
Result<T、 E>

非同期処理の戻り値の型はそれぞれ Promise など Promise でラップされた型になります.

方法ごとの特徴

ここでは以下の 3 つの観点から、それぞれの方法の特徴を見てみます。

  • 簡潔さ
  • 明瞭さ
  • 型安全性

簡潔さ

記法の簡潔さでは return に比べて throw の方が優れています。

一つは大域脱出を行う場合、return はバケツリレーをしてエラーを上位に伝播させてやる必要があります。throw の場合はそれを勝手にやってくれます。
もう一つは関数のシグネチャで、 return の場合はエラーを関数のシグネチャに含める必要があり、さらにそれが呼び出し元の関数のシグネチャにも伝播していきますが、 throw の場合はそういったことは起こりません。
ただし、次の「明瞭さ」の観点ではこの価値が逆転します。

明瞭さ

関数の発生させるエラーがどういったものであるか、という明瞭さの点では、 throw に比べて return が勝ります。

throw は、ある関数がエラーを発生させるのか、 どういったエラーが発生し得るかは、関数のシグネチャには全く含まれません。
これは非同期処理の場合も同様で、確かに Promise はエラーが発生する可能性を内包してはいますが、エラーが発生しない非同期処理も Promise で表されるため区別ができませんし、発生するエラーの種類も分かりません。

return の場合は関数の戻り値の型を見れば、その関数がエラーを発生させるのかや、どういったエラーを発生させるのかを判断することができます。

型安全性

型安全性についても throw に比べて return の方が優れています。

まず throw の場合、エラーハンドリングには try { … } catch (err) { … } を使うことになりますが、この err の型は unknown (TypeScript 4.4 より前では any) です。 もしエラーの具体的な内容を見てなんらかの処理を行いたいのであれば、必然的に動的な型検査2をするか、または安全でないキャストを行うことになります。

Promise の rejection についてもほぼ同様で、promise.catch((err) => { … }) とした場合の err の型は any です。 Promise にはエラーの型を表すパラメータはないので、これを回避する方法もありません。

どちらの方法が適しているか

ここまでで throw は簡潔さ、return は明瞭さと型安全性でより優れていることがわかりました。

続いて以下の 3 つの軸が変化したときに、それぞれの方法の許容度がどのように変化するかを考えてみます。

  • プログラムの規模
  • エラーの種類
  • エラーハンドリングの方法

プログラムの規模

プログラムや開発チームの規模が小さい場合、あるいは小さく済ませたい場合は、 「簡潔さ」の重要性が相対的に高くなります。 つまりこの場合はより簡潔な throw の許容度が高いため、return ではなく throw を使うという選択も妥当と言えます。

逆にプログラムや開発チームの規模が大きくなってくると、「明瞭さ」や「型安全性」の重要性が相対的に高まってきます。 こういった場合は throw よりも return の方が許容度が高いと言えるでしょう。

エラーの種類

エラーの種類は以下のように 2 つに大別できます4。

発生が予期できて、プログラムでハンドリングされるべきエラー
発生が予期されず、プログラムでハンドリングする必要のないエラー
前者はユーザー入力のバリデーションエラーや通信時のエラー、後者は事前条件が満たされないといった契約に関するエラー (バグ) などが該当します。

前者のように、エラーがプログラムでハンドリングされるべきなのであれば、適切なハンドリング処理が書かれやすくなるように「明瞭さ」の重要性が高くなります。 また具体的なエラーの内容まで扱うのであれば「型安全性」の重要度も相当に高くなります。 こういったエラーに対しては return の許容度が高いと言えるでしょう。

後者の場合はそもそもハンドリングする必要がないので、「簡潔さ」の方が重要度としては高くなり、return はむしろ冗長に見えてきます。 こちらののエラーについては throw の方が許容度が高そうです。

エラーハンドリングの方法

エラーの種類の節でも述べた通り、エラーハンドリング時にエラーの内容を具体的に扱いたい場合は「型安全性」が重要になってきます。

例えばユーザー入力のバリデーションのように、エラーの理由を具体的にフィードバックすべき場合については、発生したエラーの具体的な内容を読み取ることになります。 この場合は throw と比べてより型安全な return の方が許容度が高いと言えそうです。

一方で、場合によってはエラーが発生したことさえわかれば十分ということもあります。 例えば API など外部との通信を伴う処理は、経路上のさまざまな原因で失敗することがあります。 これらの個別の原因はデバッグには役に立つかもしれませんが、プログラム中で詳細なハンドリングをする必要はほぼありません。 こういった場合は「型安全性」の重要度が低いため、throw の許容度も高くなる言えます。