Option / Result 型

null や例外の代わりに、値の有無やエラーを型で表現する関数型プログラミングのパターン

型システム品質

Option / Result 型とは

Option 型と Result 型は、null や例外の代わりに、値の有無やエラーを戻り値の型そのものに埋め込むパターンである。「値が無い状態」「失敗した状態」を型が取り得る 2 つの形として表すため、取り出す前の分岐をコンパイラが要求できる。

言語ごとに名前は違うが構造は同じものである。

概念RustHaskellScalaTypeScript
値の有無Option<T> (Some / None)Maybe a (Just / Nothing)Option[A] (Some / None)判別可能なユニオンで自作
成功か失敗Result<T, E> (Ok / Err)Either e a (Right / Left)Either[E, A] (Right / Left)同上、または neverthrow などのライブラリ

Haskell と Scala の EitherLeft を失敗、Right を成功に割り当てる慣習で使う。型の上ではどちら側を失敗に使っても構わないが、標準の関数群 (fmap など) が Right 側にだけ作用するため、慣習に逆らうと合成が噛み合わなくなる。

null の問題

null が危険なのは、「値が無いかもしれない」という情報が型に書かれていても、それを無視した参照が書けてしまう場合があるからである。TypeScript は strictNullChecks が有効なら下のコードをコンパイル時に弾く。

function findUser(id: string): User | null {
  return db.get(id); // null かもしれない
}
const user = findUser('123');
console.log(user.name); // ❌ error TS18047: 'user' is possibly 'null'

問題が残るのは検査が効かない経路である。strictNullChecks を切った設定、any を経由した値、JSON.parse の戻り値のようにコンパイラが中身を知らない値、そして null が全参照型に潜む Java のような言語では、同じ書き方が実行時の TypeError まで生き延びる。Option 型の値打ちは、値の有無をコンパイラ設定ではなくデータの形として持てる点にある。型定義を読んだだけで「取り出す前に分岐が必要」と分かり、設定を緩めても構造は崩れない。null との対比は Null 安全性 も併せて参照されたい。

TypeScript での Option 型

判別用のフィールド (ここでは kind) を 1 つ持たせると、値の有無が型の形として現れる。valuekind: 'some' 側の枝にしか存在しないため、分岐を書かずに触ると error TS2339: Property 'value' does not exist on type で弾かれる。「取り出す前に必ず分岐が入る」という規律を、規約ではなく型が担保する形になる。

type Option<T> = { kind: 'some'; value: T } | { kind: 'none' };

const some = <T>(value: T): Option<T> => ({ kind: 'some', value });
const none: Option<never> = { kind: 'none' };

function findUser(id: string): Option<User> {
  const user = db.get(id);
  return user ? some(user) : none;
}

const result = findUser('123');
if (result.kind === 'some') {
  console.log(result.value.name); // ✅ 型安全
}

TypeScript での Result 型

Result 型は成功と失敗を 1 つの戻り値にまとめる。例外と違って呼び出し側は ok を確かめない限り値を取り出せず、失敗の型も宣言に現れる。

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

function parseJson<T>(raw: string): Result<T, string> {
  try {
    return { ok: true, value: JSON.parse(raw) };
  } catch (e) {
    return { ok: false, error: `Parse error: ${e}` };
  }
}

const result = parseJson<User>('{"name":"Alice"}');
if (result.ok) {
  console.log(result.value.name); // ✅ 型安全
} else {
  console.error(result.error);    // ✅ エラーも型安全
}

ただしこの例の ok: true が意味するのは「JSON として構文が正しかった」ことだけである。JSON.parse の戻り値は any なので value: T と名乗れているにすぎず、User の形をしているかは何も検査していない。外部入力を扱うなら、Result で包む段とは別に中身のスキーマ検証 (zod などの実行時検証) を置く必要がある。

Rust の Option / Result

Rust では両者が標準ライブラリの列挙型で、match が全ての枝を要求するため取り出しに分岐が強制される。

use std::error::Error;
use std::fs;

// Option<T>: Some(T) か None のどちらか
fn find_user(users: &[User], id: &str) -> Option<User> {
    users.iter().find(|u| u.id == id).cloned()
}

// Result<T, E>: Ok(T) か Err(E) のどちらか
fn load_port(path: &str) -> Result<u16, Box<dyn Error>> {
    let text = fs::read_to_string(path)?;  // io::Error になり得る
    let port: u16 = text.trim().parse()?;  // ParseIntError になり得る
    Ok(port)
}

? は失敗を素通しで返す演算子ではない。Err の中身を関数の戻り値のエラー型へ From で変換してから return する。上の関数は 2 種類のエラーを返し得るので、両方から変換できる Box<dyn Error> を戻り値に置いている。ここを Result<u16, io::Error> と書くと、ParseIntError から io::Error への変換が存在せず ? の行がコンパイルエラーになる。自分でエラー型を定義して変換を用意する (thiserror#[from] など) か、map_err で明示的に潰すかの選択になる。

失敗の扱いには非対称がある。Result#[must_use] が付いているため戻り値を捨てると warning: unused Result that must be used が出るが、Option を捨てても警告は出ない (rustc 1.93 で実測)。「失敗したかもしれない」は無視されないよう守られており、「値が無いかもしれない」は型の形だけで守られている、という差である。

try-catch との比較

例外は「起きたことを型に書かずに伝える」仕組み、Result は「戻り値に書いて伝える」仕組みである。差が出るのは主に次の 4 点である。

観点try-catchResult 型
エラーの型unknown (TypeScript の strict 既定)宣言した型そのもの
網羅性チェック拾い漏れを検出できない分岐漏れをコンパイラが検出できる
エラーの伝播throw (通過する関数の型には現れない)戻り値なので通過する関数の型にも現れる
パフォーマンス送出時にスタックを巻き戻す (送出しなければほぼ無コスト)通常の戻り値と同じ

エラーの型については誤解が多い。TypeScript 4.4 以降は strictuseUnknownInCatchVariables が有効になるため、catch (e)eany ではなく unknown である (e.messageerror TS18046: 'e' is of type 'unknown' で弾かれる)。型が付いたのではなく「何が飛んでくるか分からないことが明示された」だけなので、instanceof Error で絞る手当ては依然として必要になる。

網羅性チェックも自動で付いてくるものではない。Rust の match は枝の漏れを拒否するが、TypeScript で同じ保証を得るには判別可能なユニオンに対する switch と、never 型を使った到達不能検査を自分で書く必要がある (型の絞り込み を参照)。

neverthrow (TypeScript ライブラリ)

Result 型を自作すると、mapandThen のような合成用の関数も自作することになる。neverthrow はそれらを備えた TypeScript 向けの実装で、ok / err で包み、map は成功値だけ、mapErr は失敗値だけを変換する。分岐を書かずに変換を連ねられるのが自作との差である。

import { ok, err, Result } from 'neverthrow';

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return err('Division by zero');
  return ok(a / b);
}

divide(10, 0)
  .map(v => v * 2)
  .mapErr(e => `Error: ${e}`);

ただし TypeScript には Rust の #[must_use] に相当する仕組みが無い。上の divide(10, 0) の戻り値をそのまま捨てても型検査は何も言わないため、エラー処理の抜けを機械的に止めたいなら未使用の戻り値を禁じる lint ルールを併用することになる。

落とし穴と使い分け

  • unwrap や非 null 断定への逃げ: Rust の unwrap() や TypeScript の ! は分岐を省く近道だが、使った時点で型に埋め込んだ保証は消える。潰すなら既定値を与える unwrap_or、あるいは失敗理由を残す expect("設定ファイルが読めない") の方が、後から落ちたときの調査が楽になる。
  • OptionResult の行き来: 「値が無い」を失敗として扱いたい場面では ok_or / ok_or_else で理由を付けて Result へ上げる。逆に呼び出し側が理由を必要としないなら ok()Option へ落とす。どちらの型で表すかは、呼び出し側が理由で分岐するかどうかで決まる。
  • エラー型の設計コスト: 失敗の種類ごとに列挙型を作れば呼び出し側で分岐できるが、層が増えるほど変換の記述が増える。ライブラリの内部は明示的な列挙型、呼び出し側が分岐しないアプリ最上層は Box<dyn Error>anyhow のような型消去、という使い分けが現実的である。
  • 境界での翻訳が必ず要る: すべてを Result で書いても、例外を投げる既存 API や HTTP レスポンスとの境界では変換が発生する。Result で通すのは自分が書く層までと決め、外縁で例外やステータスコードへ翻訳する形にすると、2 つの流儀が混在する混乱を避けられる。

この記事は役に立ちましたか?

関連用語

関連する記事