TypeScript エラー処理パターン

M 年前にも N 年後にも人類は同じ話をしている.

まとめ

  • エラーの発生方法は throw と return に大別できる
  • throw には簡潔さ, return には明瞭さと型安全性といった特徴がある
  • どちらの方法がより適しているかはプログラムの規模, エラーの種類, ハンドリングの方法などが判断の材料になる
  • 実際にどちらの方法を使うかは上の判断材料と, フレームワークやプロジェクトのコーディング規約なども合わせて複合的に決めるのがよい

エラー発生方法の分類

まず前提として, 関数から呼び出し元にエラーを伝える方法は以下の 2 つに大別できます. 逆にこの記事ではこれ以上の具体的な方法についての議論はしません.

throw

エラーを throw して呼び出し元に伝える方法です. 例えば以下のようなものが当てはまります.

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

Promise の rejection については async 関数内の throw と同等なため, こちらに分類しています.

return

エラーも戻り値の一種として, return1 で呼び出し元に伝える方法です. 関数の戻り値の型は例えば以下のようなものになります.

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

非同期処理の場合は戻り値の型はそれぞれ Promise<T | undefined> など Promise でラップされた型になります.

方法ごとの特徴

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

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

簡潔さ

記法の簡潔さの観点では, return に比べて throw の方が優れています. さすがに言語組み込みの機構なので強いですね.

一つは大域脱出を行う場合で, return の場合は自分でバケツリレーをしてエラーを上位に伝播させてやる必要がありますが, throw の場合はそれを勝手にやってくれます.

もう一つは関数のシグネチャで, return の場合はエラーを関数のシグネチャに含める必要があり, さらにそれが呼び出し元の関数のシグネチャにも伝播していきますが, throw の場合はそういったことは起こりません. ただし, 次の「明瞭さ」の観点ではこの価値が逆転します.

明瞭さ

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

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

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

型安全性

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

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

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

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

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

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

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

プログラムの規模

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

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

エラーの種類

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

  • 発生が予期できて, プログラムでハンドリングされるべきエラー
  • 発生が予期されず, プログラムでハンドリングする必要のないエラー

前者はユーザー入力のバリデーションエラーや通信時のエラー, 後者は事前条件が満たされないといった契約に関するエラー (バグ) などが該当します.

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

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

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

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

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

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

実際にどの方法を使うべきか

上のような許容度に基づいて throw と return のどちらがより適しているか判断できるようになったところで, 実際にどちらを使うべきかはフレームワークやプロジェクトのコーディング規約なども参考に複合的に決めるのがよいでしょう.

実際に私個人がどちらを使うべきかを考えた例をいくつか紹介しておきます:

  • 100 行程度の GitHub Actions や 300 行程度の AWS Lambda の関数5を書いたときは, 規模が小さいのでたとえ明瞭でなくても読解が容易なこと, エラーハンドリングはログを出す程度で具体的なエラーの内容にまで踏み込んだ処理はしないこと, 全体を通して使用するライブラリが throw を使った設計であったことから, 簡潔さを優先して throw を使いました
  • サーバーの実装 (1,000 行〜) では, ユーザーに HTTP 4xx を返すべきエラーについては, 具体的なステータスコードの決定を確実かつ型安全に行えるよう return, 5xx になるべきエラーについては発生し得る箇所も多いため, 簡潔さを取って throw といったように使い分けます. 多くのフレームワークでは throw しておくとフレームワーク側で 5xx エラーを返してくれたり, ログに残せたりするのも理由の一つです
  • 比較的大規模なフロントエンド (100,000 行〜) を設計したときは, 原則としてはエラーの種類に基づいて, ハンドリングすべきエラーは return, そうでないエラーは throw することとしました. ライブラリなどとの間にギャップがあれば適宜変換してギャップを埋めます. ただし文脈からエラーの発生がすでに明瞭で, かつエラーを型安全に扱う必要がない場合などについては, 簡潔さを優先して throw も許容しています

  1. 重箱の隅: 継続渡しスタイルの場合は受け取った継続の呼び出し
  2. 動的な型検査をしている場合は安全であると言えなくもないですが, 私個人としては静的に検査できる方法が第一の選択肢であるべきと考えています
  3. 仮にパラメータがあったとしてもあまり役に立たないことが多そうで, 例えば async 関数内で別の同期的な関数を呼び出しているとき, その中で throw されるかは現行の TypeScript の仕組み上推論ができないため, 多くの場合で any にならざるを得ないと思われる
  4. Java の検査例外の仕組みなんかも参考
  5. 実は JavaScript で書いたけど TypeScript で書いていても同じ判断をしていると思う