Option / Result 型
null や例外の代わりに、値の有無やエラーを型で表現する関数型プログラミングのパターン
Option / Result 型とは
Option 型と Result 型は、null や例外の代わりに、値の有無やエラーを戻り値の型そのものに埋め込むパターンである。「値が無い状態」「失敗した状態」を型が取り得る 2 つの形として表すため、取り出す前の分岐をコンパイラが要求できる。
言語ごとに名前は違うが構造は同じものである。
| 概念 | Rust | Haskell | Scala | TypeScript |
|---|---|---|---|---|
| 値の有無 | 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 の Either は Left を失敗、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 つ持たせると、値の有無が型の形として現れる。value は kind: '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-catch | Result 型 |
|---|---|---|
| エラーの型 | unknown (TypeScript の strict 既定) | 宣言した型そのもの |
| 網羅性チェック | 拾い漏れを検出できない | 分岐漏れをコンパイラが検出できる |
| エラーの伝播 | throw (通過する関数の型には現れない) | 戻り値なので通過する関数の型にも現れる |
| パフォーマンス | 送出時にスタックを巻き戻す (送出しなければほぼ無コスト) | 通常の戻り値と同じ |
エラーの型については誤解が多い。TypeScript 4.4 以降は strict で useUnknownInCatchVariables が有効になるため、catch (e) の e は any ではなく unknown である (e.message は error TS18046: 'e' is of type 'unknown' で弾かれる)。型が付いたのではなく「何が飛んでくるか分からないことが明示された」だけなので、instanceof Error で絞る手当ては依然として必要になる。
網羅性チェックも自動で付いてくるものではない。Rust の match は枝の漏れを拒否するが、TypeScript で同じ保証を得るには判別可能なユニオンに対する switch と、never 型を使った到達不能検査を自分で書く必要がある (型の絞り込み を参照)。
neverthrow (TypeScript ライブラリ)
Result 型を自作すると、map や andThen のような合成用の関数も自作することになる。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("設定ファイルが読めない")の方が、後から落ちたときの調査が楽になる。OptionとResultの行き来: 「値が無い」を失敗として扱いたい場面ではok_or/ok_or_elseで理由を付けてResultへ上げる。逆に呼び出し側が理由を必要としないならok()でOptionへ落とす。どちらの型で表すかは、呼び出し側が理由で分岐するかどうかで決まる。- エラー型の設計コスト: 失敗の種類ごとに列挙型を作れば呼び出し側で分岐できるが、層が増えるほど変換の記述が増える。ライブラリの内部は明示的な列挙型、呼び出し側が分岐しないアプリ最上層は
Box<dyn Error>やanyhowのような型消去、という使い分けが現実的である。 - 境界での翻訳が必ず要る: すべてを Result で書いても、例外を投げる既存 API や HTTP レスポンスとの境界では変換が発生する。Result で通すのは自分が書く層までと決め、外縁で例外やステータスコードへ翻訳する形にすると、2 つの流儀が混在する混乱を避けられる。
この記事は役に立ちましたか?
関連用語
Null 安全性
null 参照エラーを型システムで防止し、null の可能性がある値を明示的に扱うプログラミング言語の機能
Enum (Rust)
Rust の列挙型で、各バリアントがデータを持てる代数的データ型
パターンマッチ
値の構造に基づいて分岐処理を行う制御構文で、switch 文の強化版
型アサーション
TypeScript でコンパイラに型情報を明示的に伝える構文で、誤用するとランタイムエラーの原因になる
型の絞り込み
TypeScript の型ガードや制御フロー分析で、ユニオン型をより具体的な型に絞り込む手法
TypeScript
JavaScript に静的型付けを追加した言語で、大規模開発の安全性と生産性を向上させる