社内の「強い思想を語るLT会」で使用した資料です。
筆者はHaskellやF#、RustなどResult型ネイティブな言語が好みの人間なので、だいぶ偏った見方になっている点はご了承ください。
はじめに
みなさん、Result型(Either型)ってご存知ですか?
HaskellやElm、Rust、Kotlin、Swiftなどモダンと言われる言語には大体実装されている型です。
筆者は最近、理想のResult型ライブラリを求めて自作するに至りました。
どんな型?
ざっくり表現すると処理に失敗する可能性があることを示す型です。
(Monadとか細かい話は置いておく)
言語やライブラリによって命名などが微妙に異なりますが、TypeScriptで示すとこんな感じになります。
// どれもやってることは同じ
type Result<T, U> = Success<T> | Failure<U> // Swift的な実装
type Result<T, U> = OK<T> | Err<U> // Rust的な実装
type Either<T, U> = Right<T> | Left<U> // Haskell的な実装
// 類似の型としてはMaybe型(Option型)と呼ばれるものもあります
type Maybe<T> = Just<T> | Nothing // Haskell的な実装
type Option<T> = Some<T> | None // Rust的な実装
type Option<T> = T | undefined // JavaScript的な実装
JavaScriptには標準の例外処理機構があるじゃない?
使いにくい!!(個人的な感想です)
JavaやSwiftのように例外の型を上流に伝播できない
- 具体的な実装や仕様はドキュメント見ればいいという意見は筆者も相違ありません
- しかし、失敗の可能性があるのか(可能であればどのような失敗が考えれられるのか) はコードではっきりさせてほしい
JSDocを書けばいい?
- それでみんなが満足していたらそもそもTypeScriptは生まれてない
- JavaScriptの表現は限界があったから、TypeScriptは誕生したはず
- Denoなど一部のプロジェクトがJavaScriptに回帰した例もありますが…
- コンパイラで弾けない以上、コメントがあっても気づかず例外を発生させてしまう恐れがある
- こちらの方法でも例外を上流に伝播させることはできない(もし可能な方法があれば教えていただけると幸いです)
Result型を使うとこんないいことがあるよ!
以下の内容は@totto2727-org/resultでより詳細に記述しています。
Result型なし
// NANを返すことは避けたい
/**
* @throws {RangeError}
*/
function divide(dividend: number, divisor: number): number {
if (y === 0) {
throw new RangeError("0除算は禁止されています");
}
return dividend / divisor;
}
// 型からは例外が起こり得ることを読み取れない
// 例外発生!
const quotient = devide(1, 0);
// 実行前にアプリケーションが異常終了
console.log(quotient);
Result型あり
import * as r from "https://esm.sh/@totto2727/result"
// NANを返すことは避けたい
function divide(dividend: number, divisor: number): r.Result<number, string> {
if (y === 0) {
return r.fail("0除算は禁止されています");
}
return r.succeed(dividend / divisor);
}
// 型レベルで処理に失敗することが示されている
// 型ガード無しで結果の値を利用することができない
const quotient = devide(1, 0);
// 失敗したか判別するユーザ定義型ガード
if (r.isFailure(quotient)) {
// 以下quotientはFailure型として処理される
consolo.log(quotient.cause)
// output: 0除算は禁止されています
}
// 以下quotientはSuccess型として処理される
console.log(quotient.value)
// output: ${計算結果}
安心感が違う
- コードを見るだけで少なくても例外が発生する可能性がわかる
- (TypeScriptにおける)Result型では型の検証を飛ばして値を使用することは原則できない
とは言っても不便なところも(銀の弾丸は存在しない)
- パターンマッチングがない
- 型ガード関数で誤魔化してる
- 他のライブラリでは普通にthrowしてくる
- SupabaseやZodのようにResult型に近い型を返すライブラリもある
- 簡単にラッパーを作る関数、メソッドが必要(自作ライブラリは実装済み)
- クライアントサイドとサーバサイドでスムーズにシリアライズできるのか?
- クラスを使っていなければ大丈夫(自作ライブラリは大丈夫)
- 有名ライブラリではクラス実装のものも多いため、近年のSSR可能なフロントエンドFWで使う場合は注意が必要
- コード量が増える
- 一箇所Resultだと全部Resultに汚染される可能性がある(実装により改善可能)
- ネストがやばい
- 実装があまり良くない可能性もありますが、以下のようになると流石にわかりにくい
- Result型の中にResult型
- オブジェクトを値にもつResult型の中にResult型
- Promiseの中のResult型の中のResult型のPromise型
割り切りも大事
記事名を裏切るような話ですが、本当に100%すべてResult型で管理し切るのは厳しいところがあると考えています。
必要に応じてunwrapする
自作のライブラリではunwrap
というResult型を通常のtry...catch文で使えるように変換する(戻す)関数をデフォルトで提供しています。
const result = returnResult();
// Failure型だとthrowされる
// 〇〇なのでunwrapします
const value = unwrap(result);
全部Resultで処理するのでは?
- あまりに冗長な実装が生じる可能性がある
- Rustもマクロやunwrapでpanicを発生させることがある
- フロントエンドにおいて
- コンポーネントや状態管理まで汚染される可能性がある
- JotaiのAtomがResult型まみれになるのは地獄でした(実体験)
- 同じ型ガードを何度も行う羽目になった
- コードの行数も嵩むため、コードの把握がしづらくなった
- Next.jsのApp Routerや類似FWには大体ErrorBoundary的な機構がある
- コンポーネントや状態管理まで汚染される可能性がある
個人的な運用方針
- 自分で書いたコードはResult型を厳密に使う
- 外部APIやサードパーティのライブラリを使用する場合はResult型でラッパーを作る
- これにより、実質的な再エクスポートとなるため、自然とライブラリの変更に強い実装ができる
- unwrapを適度に使う
- でも必ずコメントでunwrapした意図を残しましょう
- いわゆるViewに近い領域(状態やコンポーネントのPropsなど)にはResult型を残さない
- 特にJotaiやRecoilのプリミティブなAtomではResult型を使わない(unwrap or default valueで対処)
- 復帰の難しい致命的な処理の失敗はErrorBoundaryに任せる
- ErrorBoundaryに任せる都合上、ルーティングやコンポーネントの設計が一層大事になってくる
- 状態にsetするまで(HookやAtomの途中の処理)はガンガンResult型を使っていく
- サーバコンポーネントはギリギリResult型OKかな…?(要研究)
最後に
Result型はいいぞ
自作のライブラリもあるので皆さん使ってみてください!
参考
記事の本筋とは関係ないけど、LT会で話したかったことを以下に残しています。
Result型のTypeScript実装
多分一番有名で高機能
自作
その他(以下の他にも色々な実装があります)
パターンマッチング
他言語の羨ましい機能。これがあるとResult型が一層扱いやすくなります。
TypeScriptの思想的に無理…黒魔術を使えばできないこともない
mightFail :: Either String Int
main =
case mightFail of
Right x -> putStrLn $ "Successful result: " ++ show x
Left err -> putStrLn $ "Error occurred: " ++ err
let number_str = "10";
let number = match number_str.parse::<i32>() {
Ok(number) => number,
Err(e) => return Err(e),
};
Monad
必須ではないけど思想として知っておくと便利