そんなthrowで大丈夫か?(ホイホイ例外を発生させる是非について)
むかし自分が書いたコードを読み直していたら、こんなコードがありまして:
class Item {
constructor(private name: string) {
if (name.length < 4 ) {
throw new Error('名前は四文字以上じゃないとダメだよ!')
}
}
}
const item1 = new Item('okなアイテム')
const item2 = new Item('ng') // throwする
随分気軽にthrowしてるけど、そんなthrowで大丈夫か?
throwすると何が起きるのか
初期化に失敗したときに何かロジックを実行する必要があれば(異常系があれば)、さっきのようなコードは呼び出し側でcatchする必要が生じます:
class HogeUsecase {
public do(input: string) {
try {
const item1 = new Item(input)
} catch(e) {
// ここで例外を処理する
}
}
}
しかし「Itemクラスはthrowする可能性がある」ことはインターフェースを見ただけでは分かりません(TypeScriptを前提として考えてます)。throwすることを知るためにはItemのコードを読み解かなければいけず、今回のような短いコードならまだしも、Itemが3000行ぐらいのモンスターコードだったら見落としちゃいそうですよね
Itemがthrowすることを見落とした人が、こんなユースケースでItemを初期化したと仮定します:
// このユースケースは結構大事なので失敗したら必ずメール通知する必要がある
class HogeUsecase {
public do(input: string) {
const item1 = new Item(input) // new: throwしないと思い込んでtry-catchの外に追加したコード
try {
someNewAction(item1) // new: item1を使用する新たな処理
throwableImportantAction(input)
} catch(e) {
// このユースケースが正常に完了しなかったらメール通知するようになっている
}
}
初期化に失敗してユースケースが正常に終了しなくてもメール通知が届かないコードが簡単に出来上がりました。
呼び出し側がコードの使い方についてたくさん知らなければいけない(例外が発生することを知らなければ適切に使えない)コードを量産してしまうと、このように元々あったロジック(正常終了しなかったら必ずメール通知)をすっ飛ばすコードを誤って書いてしまうリスクが増加します。
これは 「正常系の処理を全てすっ飛ばして異常系の分岐にすぐ入れるようにする」 という例外の特性に起因していて、この特性が効果的なケースもあればそうでないケースもあるため、無条件に使うのは考えものではないか?という問題提起が今回の記事の要旨です
例外の代わりにモナド
例外を発生させる以外にどのような対応策が考えられるのでしょうか?
ひとまず関数型言語で馴染み深いモナド
を使うパターンを紹介しようと思います。例えばResultとかMaybeで表現されることが多いパターンを使えば、こんなコードが書けます:
class HogeUsecase {
public do(input: string) {
const item1: Maybe<Item> = new Item(input) // ItemではなくMaybe<Item>型を返す
try {
someNewAction(item1) // Itemではなく Maybe<Item> 型なのでビルドできず、実行もできない
throwableImportantAction(input)
}
}
先ほどはItem
型を返していたところが Maybe<Item>
型になっているため、そのままsomeNewActionに渡せなくなっています。イメージとしてMaybeは「 もしかしたらItemかもしれないけど、そうじゃないかもしれないから確認してね 」という意味を付与した新たな型ですね
Maybe<Item>
をItem
に変換するには、こういうコードを一度挟まなければいけません:
if (item1.isOk()) {
item1.value // isOk(結果)を一度チェックしないとItem型は取り出せない
}
こうすることでItemクラスの中を読みに行かなくても、Itemは初期化に失敗する可能性があることを Maybe<Item>
型の存在によりビルド時にコンパイラが教えてくれる ので、例外を使ったケースのように「えっ、Itemって初期化に失敗することあるの!?」みたいな考慮漏れは発生しづらくなります。もちろんこの書き方だと異常系の処理を書き忘れる可能性が残っているので完璧に防げるわけではないのですが、予想外の例外が発生するコードよりは異常系の存在が事前に示唆されているコードの方がリスクは減ると思うんですよね
比較してみると
- 例外のパターン:コードを読みに行かないと例外が発生するか分からない、例外をキャッチしなくてもコンパイラは注意してくれない(個人的にこれが一番きつい)、try-catchのネストが深くなっていく(これもキツイ)、キャッチしないと例外が伝播して想定外の処理に入り得る(これも...)
- モナドのパターン:コンパイラが異常系の可能性を教えてくれる、チェックしないとそもそもビルドできない
もちろん正常系の処理を全部すっ飛ばしてfail fastした方が良いケースなど、例外の方が適したケースも多々あると思うのですが、自身が管理しているアプリケーションのロジックを書いてる時に例外を無邪気に使いまくるのは再考の余地があるかもしれない、という趣旨でした
「そんなthrowで大丈夫か?」
「一番eのを頼む」
こんな感じ。違うか。
ジェネリクスが出てくるので、もしジェネリクス周りに不安があればこちらの記事もどうぞ
補足
普通コンストラクタで自身のクラスと異なるクラスは返せないので、モナドを初期化に使う場合コンストラクタはprivateにしておいてpublicなstaticメソッドからItemを生成するようにしてます。こんな感じ:
const item1 = Item.instantiate()
なので今回の例に出しているコード(new Item
したらMaybe<Item>
が返ってくる)は少しヘンテコなコードになっていますが、説明の簡単のために許してクレメンス
Discussion
大変興味深い記事をありがとうございました。特に「例外をキャッチしなくてもコンパイラは注意してくれない」という点に関しては非常に同意するところです。
一点気になったことがあります。例外を利用したパターンでは value object から「失敗した理由」を例外として投げていますが、Maybeを使ったパターンではそれが失われているように見えます。ここはユーザーに失敗の理由を提示するに当たって大事な箇所かとも思いますので、例外を使わない場合にはどのようにそれを行っているのでしょうか?
ありがとうございます!
失敗理由が必要な場合はneverthrowのように、エラーだった場合のみ返すエラーオブジェクトを用意して、そちらを見て判断する必要がありそうだと感じました。ただ「Maybe」という名前を見た時おそらく一般的には「値があるか否か」という振る舞いを期待すると思うので、失敗理由も返ってくる場合はMaybeという命名は適切ではない気がしますね...
お返事ありがとうございます。
Maybeは成功か失敗のみを表す表現かと思って読んでいましたので、やはりこういうシーンではいわゆる Result 型的なものが必要となりそうですね。
本記事で表現したいことをちょっと考えてみてneverthrowのResult型を使ったデモを用意してみました。的外れでしたら、ご容赦くださいませ。
エラーパターン4つでデモ用意してみました。
簡単ですが、以上です。