neverthrowと比較したoption-tについて

たまたまoption-tを紹介してくれるブログ記事があったみたいで、その上でneverthrowの方がおすすめみたいな感じのtweetを見かけて、色々言いたいことがあったので久々に書いてみる。

あ、元々のブログについてはあんまり思うことはないです。「まあ、プロジェクトごとに好きにすればいいんじゃない?」って感じ。try-catchが良いか悪いかは山のように議論があるから、そっちでやってくれ。

自分の好みを問われるならば、それはoption-tのMotivationを読んでくれとなるが、その上で、 プロジェクト固有のconvention(規約)や設計指針・依存するライブラリとの接続によっては、必ずしも Result ( Either )型を使うのがベストな選択であるとは限らない のは勿論だと強調しておきたい。

ECMAScriptと関連するspec群においてtry-catchと例外の機構自体は一級市民であるのは揺るぎもない事実であり、それを無理に自前の規約でひっくり返すのが良いかは場合による….としか言いようがない。素直に例外とnull武装する方がいいことも多い。そのために T | nullとかT | undefinedを対象にしたツールキットをoption-tは提供しているわけだし。

あ、option-t関係ないけど、Error.prototype.causeについては本当に便利で最高

neverthrowとoption-tを比較した場合の話

必要だと思ったものを継ぎ足してるんで、いつ比較したのかの時期に依存すると思うんだけど、ここでは

を底として話す。

誤解1: async周りの対応がoption-tには無い

./docs/public_api_list.mdAsyncって名前の入ってるやつがそう。

誤解2: try-catchを Result 型に変換する仕組みがoption-tには無い

あります。今までは各ユーザープロジェクト側で必要に応じて実装してたけど、流石に毎回再実装するのがだるいので2022年の夏頃に実装した。

neverthrowのfromPromisefromThrowable と関数のシグニチャが違うのは設計の前提となる洞察の差異だと思っている。

Option-t側に実装するに際して、「try-catchから Result 型をJS(TS)で作りたい状況とは如何なるか?」を検討した結果、自分は以下の2ケースだと判断した。

  • なんでもいいからtry-catchをResult<T, unknown>に変換したい場合
  • catch節で掴んだものがErrorであると何かしらの保証を加えたい場合
    • つまり、 stringとかnumberが`throwされているのは流石に弾きたいような場合

Result<T, unknown>を具体的なResult<T, E>に変換したいならば mapErr()を使って変換すればいいしね。

neverthrowはそれの組み合わせを結合した結果、現在のAPIシグニチャになっているのだろうと思う。

誤解ではないが弁護したい: option-tはドキュメントが少ない

これはかなり意図してそうなっている。

まず、そもそもとして冒頭でも述べたように、自分は実際には各プロジェクトのmotivationに依存して最終的なconvention(規約)が決定されるのであって、ライブラリそのものに強い色をつけるのが妥当と思えず、この種の一般的なデータ構造の実装を提供するライブラリそのものはデータ構造の提供に止めるべきであろう、ましてエラーハンドリングのようにコードの設計や運用指針まで影響するものであれば尚更、という設計意図が非常に強い。

第二に、ドキュメントを書いてもメンテナンスしきれない。仕事でも使うけど実質趣味活なので、メンテナンス性の観点で割り切っている。

第三に、Rustのstd::resultstd::optionと類似のAPIのJS版実装からプロジェクトが始まったので、わざわざ自分でドキュメントを用意する必然性が薄い。

第四に、各関数単位ではvscodeなどのインラインガイド用にJSDocコメントを大量に書いているのと、実装自体がいずれも単純かつ極小(数行)なので、「困ったら実装をコードジャンプして参照してくれ」という感じ。

ただ、流石に何もなさすぎると言われればそうかもしれないので、オペレータ関数の逆引きみたいなものは作ってもいいかなと思っている。

貧弱だけど既にあった。自分で追加したのを忘れていた....

neverthrowと比較した場合の設計意図の違いについて

ざっと見た場合、以下が設計方針として違うかなと思う。

  • documentation(上で述べた通り)
  • メソッドチェーンの有無
    • classベースの実装か否か
    • tree shakingのサポート
  • Result 型以外の実装も提供している

option-tはメソッドチェーン記法を明確に捨てている。

実際これは結構意見が分かれるところだと思っているし、初期はメソッドチェーン版を実装していた(現在も非推奨ながら後方互換と好みの問題で残してある)。

捨てた理由としては、まさにtree shakingの可否すなわちバンドルサイズへの影響。

一般的なアプリケーションから、配布サイズにセンシティブなSDKや配信ウィジェットでの利用まで含めて実用的にしようと思うと、この選択となった。

Result 型に対するオペレータ関数は実質無限に近しいパターンを実装可能であり、頻出パターンはライブラリとして提供するのが望ましい一方、それら全てが常に使われるわけでは無いという問題と隣り合わせである。

JavaScriptの場合、class表記でも単純なオブジェクトでも、プロトタイプチェーン上に乗っかってしまった未使用プロパティ(メソッド)の安全な削除は非常に困難。プログラム全体を俯瞰して使用されているか否かが静的に決定できない場合、未使用プロパティメソッドといえども削除できない。仮にprototype chainを全て舐めるリフレクション的な操作が入ってきた瞬間に不可能になる。

これをサポートしているツールチェインも、例外的にGoogle Closure Compilerのadvanced optimization mode程度で、現代においては決してメジャーかつ一般的な選択肢では無いので、設計上の選択肢から外れた。

他にも当時(2017年とか?)のTypeScriptの型推論機構と組み合わせた場合に、メソッドチェーンの途中で推論が諦められてジェネリクスの型が anyにすぐ落ちてしまうので明示的な注釈が必須でダルくてしかたなかったとか、semverに基づくとプロジェクトの依存関係にmajor version違いの同名パッケージが複数個存在することがあってclassベースの実装だとinstanceofで非常に直感に反する挙動となるケースがあるとか、色々あって、メソッドチェーンベースの記法を諦めて、冗長ではあるが、通常の関数呼び出しの連鎖に変更し、tree shakingの完全なサポートに舵を切った。

pipeline operatorが実現すればoption-tでももう少し書きやすくなるとは思うけれども、結局プログラムは書く瞬間よりも読まれる瞬間の方が圧倒的に長く、極端にダルい・変な書き方でない限りは愚直に書けば十分だろうと自分は強く考えているので、もっと書きやすくしたいが、これはこれでいいと思っている。

Result 型以外の実装も提供しているのは、歴史的経緯というか、もともと Option 型の提供からスタートして、その後、 Result 型や関連するデータ型に関して、1パッケージで自分の需要を全て満たそうとしたらこうなった感じ。あんまり深い理由はない。

neverthrowと共通しそうだなと思っているところ

これも勝手な推測なのだけど、オペレータ関数周りは、どちらのライブラリも

  1. 本当に基礎的なものは提供する
  2. それを組み合わせて作るドメイン特化的なものは、各ユーザープロジェクトで自由に実装してくれ
  3. よほど便利なものはライブラリ側で取り込む
    • こうするとライブラリ側固有の最適化を行える場合がある

のは共通してそう

まとめ

好きなの使えばいいよ

おまけ: 例外といえば好きなミーム