失敗の扱い方は言語ごとに違いますが、対立軸は 1 つです。失敗を通常の戻り値とは別の経路で運ぶか、戻り値そのものに載せるか。前者が例外、後者が Result 型です。

この違いは書き味の好みではありません。何が型に現れるかが変わります。

その前に、失敗は 3 種類ある

議論の前提として、プログラムの失敗は性質の違う 3 つに分かれます。

種類 性質
プログラミングエラー 範囲外アクセス、型の取り違え 基本的にバグ。回復させない
業務上の失敗 ユーザーが見つからない、残高不足 想定された分岐。呼び出し側が処理する
外部要因 ネットワーク障害、タイムアウト 実行環境依存。再試行や縮退の対象

この区別が重要なのは、例外と Result 型の得意な領域が違うからです。2 番目の業務上の失敗を例外で投げると設計が歪みやすく、1 番目のバグを Result 型で返すとコードが冗長になります。

例外は呼び出し階層をまたげる

Python のような例外モデルでは、失敗は戻り値とは別の経路で伝播します。

def load_user(user_id: int) -> User:
    raise UserNotFoundError(user_id)


try:
    user = load_user(1)
except UserNotFoundError:
    print("not found")

利点は、途中の関数が何も書かなくても呼び出し階層を自動でさかのぼれることです。10 段の呼び出しの奥で起きた障害を、一番外側でまとめて捕まえられます。

弱点は、関数のシグネチャを見ても何が飛んでくるか分からない点です。load_user(user_id: int) -> User という型は「失敗しない」とは言っていませんが、そう読めてしまいます。結果として、try を書き忘れても何も起きません。気づくのは本番で落ちたときです。

Result 型は失敗を戻り値に載せる

Rust は失敗を通常の値として返します。

enum Result<T, E> {
    Ok(T),
    Err(E),
}

fn load_user(id: u64) -> Result<User, LoadUserError> {
    Err(LoadUserError::NotFound(id))
}

呼び出し側は、成功と失敗の両方を書かないと値を取り出せません。

match load_user(1) {
    Ok(user) => println!("{}", user.name),
    Err(error) => eprintln!("{error}"),
}

シグネチャに LoadUserError が現れるので、「この関数は失敗しうる」「失敗の種類はこれだ」が型から読めます。処理漏れはコンパイル時に止まります。

冗長さは ? 演算子が引き受けます。

fn process() -> Result<(), Error> {
    let user = load_user(1)?;
    save_user(user)?;
    Ok(())
}

? は「失敗ならここで戻る」を 1 文字で書く記法です。例外の自動伝播に近い書き味を、型を保ったまま得られます。

動的型の言語では「規約」になる

Python や JavaScript にも Result 相当を自作できますが、効き方は変わります。

def load_user(user_id: int) -> User | LoadUserError:
    ...

これは型注釈であって強制ではありません。返ってきた値を分岐せずに使ってもCPython は止めません。mypy を通して初めて検査になります。

TypeScript ではもう少し強く効きます。判別可能なユニオン型にすると、分岐を尽くしていない箇所がコンパイルエラーになります。

type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function loadUser(id: number): Result<User, LoadUserError> {
  return { ok: false, error: { kind: "notFound" } };
}

ただし、型が消えるのはコンパイル後です。外部から来た値が型どおりである保証は別途必要になります。

比較

観点 例外 Result 型
正常系の見た目 すっきりする 分岐が目に見える
失敗の型表現 シグネチャに出にくい 戻り値型に現れる
伝播 自動 ? などで支援
処理漏れ 実行時まで気づきにくい 型検査で検出しやすい
想定外の障害 扱いやすい すべて値にする必要がある

どちらかが優れているのではありません。例外は想定外の障害に強く、Result 型は想定された失敗に強いという住み分けです。だからこそ Rust にも panic! があり、業務例外を Result で表す言語でもバグは即座に落とします。

まとめ

  • 失敗はバグ・業務上の分岐・外部要因の 3 種類に分かれます。どれを扱うかで適切な手段が変わります。
  • 例外は伝播が自動で想定外の障害に強い反面、何が飛ぶかがシグネチャに出ません。
  • Result 型は失敗を戻り値型に載せるため、処理漏れがコンパイル時に検出できます。
  • Rust の ? 演算子は、型を保ったまま例外に近い書き味を実現するための仕組みです。
  • Python の型注釈は検査器を通さなければ効かず、TypeScript の型は実行時に残りません。同じ書き方でも保証の強さは違います。
  • 業務上の失敗を例外で投げる設計は歪みやすい。ここだけは言語を問わず共通する落とし穴です。