エラーハンドリングというとtry-catchが定番ですが、関数型プログラミングの世界には「失敗を例外ではなく戻り値として表現する」というアプローチがあります。これを一歩進めた設計パターンが Railway Oriented Programming(ROP) です。
本記事ではTypeScriptで実際にコードを書きながら、try-catchとの違い、ROPの仕組み、そして型がどうやってその仕組みを支えているのかを見ていきます。
サンプルはすべてNode.js(22.18以降 / 23.6以降)のネイティブTypeScript実行機能を使っているので、ts-nodeやtsxを入れなくてもnode ファイル名.tsでそのまま動きます。
1. try-catch vs Result型
まずは最小の例で比較します。0除算をチェックするだけの関数です。
try-catch版
function divide(a: number, b: number): number {
if (b === 0) {
throw new Error("0で割ることはできません");
}
return a / b;
}
try {
const result = divide(10, 0);
console.log("結果:", result);
} catch (e) {
console.error("エラー:", (e as Error).message);
}
Result型版
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return { ok: false, error: "0で割ることはできません" };
}
return { ok: true, value: a / b };
}
const result = divide(10, 0);
if (result.ok) {
console.log("結果:", result.value);
} else {
console.error("エラー:", result.error);
}
どちらも動きは同じですが、divideの関数シグネチャを見比べると違いがわかります。(a: number, b: number): numberだけでは失敗するかどうかがわかりませんが、Result<number, string>と書いてあれば「この関数は失敗しうる」という情報が型として残ります。
2. Railway Oriented Programmingとは
ROPはScott Wlaschinが提唱した考え方で、処理の流れを「線路」に例えます。
- 成功トラックと失敗トラックの2本のレールがある
- 各関数は入力を受け取り、成功か失敗かのレールに処理を乗せる「分岐器」
- 一度失敗レールに乗ったら、以降の分岐器は素通りしてそのまま失敗が流れていく
この「素通りさせる」部分を担うのがandThen(ライブラリによってはbindやflatMapとも呼ばれる)という関数です。
function andThen<T, U, E>(
result: Result<T, E>,
next: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? next(result.value) : result;
}
「成功していれば次の関数を実行する。失敗していれば何もせずその失敗をそのまま返す」——これだけです。
3. 3ステップの処理で試す
文字列を数値にパースし、正の数かを確認し、逆数を取る、という3ステップの処理で比較します。
function parseNumber(input: string): Result<number, string> {
const n = Number(input);
return Number.isNaN(n) ? err(`"${input}"は数値ではありません`) : ok(n);
}
function validatePositive(n: number): Result<number, string> {
return n > 0 ? ok(n) : err(`${n}は正の数ではありません`);
}
function invert(n: number): Result<number, string> {
return ok(1 / n);
}
ROPを使わない場合、各ステップの後に手動でチェックが必要です。
function run(input: string): Result<number, string> {
const parsed = parseNumber(input);
if (!parsed.ok) return parsed;
const validated = validatePositive(parsed.value);
if (!validated.ok) return validated;
const inverted = invert(validated.value);
if (!inverted.ok) return inverted;
return inverted;
}
ROPを使う場合、andThenで連結するだけです。
function run(input: string): Result<number, string> {
return andThen(andThen(parseNumber(input), validatePositive), invert);
}
ステップ数が増えるほど、この差は開いていきます。手動チェック版は「失敗ならそのまま返す」という同じ形のコードがステップの数だけ増殖しますが、ROP版はandThenを並べるだけで済みます。
4. ROPのメリット・デメリット
メリット
- ボイラープレートが増えない。ステップを追加しても
andThenを1つ足すだけ - 失敗の伝播ロジックを書き忘れるバグが起きない(
andThenが保証してくれる) - 型シグネチャに「失敗しうる」ことが現れる
デメリット
-
andThen/mapのような語彙に慣れるまで学習コストがある - 非同期処理(
Promise<Result<T, E>>)を連結するには専用のandThenを別途用意する必要があり、自作すると複雑になりがち - 例外と違い、失敗した瞬間のスタックトレースが取れない。デバッグ時に「どこで失敗したか」を追う工夫が必要
-
fetchやJSON.parseなど例外を投げるAPIとの境界では、結局どこかでtry-catchが必要になる - 1〜2ステップしかない単純な処理には、かえって遠回りに見えることがある
実務では自作せず、neverthrowやfp-tsといったライブラリを使う選択肢もあります。
5. 読みやすさの改善:メソッドチェーン
andThen(andThen(parseNumber(input), validatePositive), invert)という書き方は、処理の順番と読む順番が逆になっていて読みにくいという弱点があります。これはandThenが「ただの関数」だからです。
Result自身に.andThen()というメソッドを持たせると、Promise.then().then()と同じ感覚でチェーンできるようになります。
type Result<T, E> = Ok<T, E> | Err<T, E>;
interface Ok<T, E> {
readonly ok: true;
readonly value: T;
andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E>;
}
interface Err<T, E> {
readonly ok: false;
readonly error: E;
andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E>;
}
function ok<T, E = never>(value: T): Result<T, E> {
return {
ok: true,
value,
andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
return fn(value);
},
};
}
function err<T = never, E = unknown>(error: E): Result<T, E> {
return {
ok: false,
error,
andThen<U>(): Result<U, E> {
return err(error);
},
};
}
run関数はこう書けるようになります。
function run(input: string): Result<number, string> {
return parseNumber(input).andThen(validatePositive).andThen(invert);
}
andThenの型が意味すること
andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E>;
この型には3つの型パラメータが登場しますが、役割はそれぞれ違います。
-
T,Eはインターフェース自身のジェネリクス。「今このOkが握っている成功値の型」と「エラー型」を表す -
Uはメソッド自身のジェネリクス。andThenを呼び出すたびに、その場で新しく決まる
Uをインターフェース側(interface Ok<T, E, U>)に書いてしまうと、Okオブジェクトを作った瞬間に「次に何の型に変換されるか」が固定されてしまい、.andThen(a).andThen(b).andThen(c)のように型が変わり続けるチェーンが組めなくなります。呼び出しのたびに違うUを選べるようにするため、メソッドスコープのジェネリクスにしているのがポイントです。
引数fn: (value: T) => Result<U, E>は「今の成功値Tを受け取り、成功値の型がUに変わった新しいResultを返す関数」という意味です。
-
成功値の型
TはUに変わってよい。例えばnumberを受け取ってstringのResultを返す関数を渡すこともできる -
エラー型
Eは変えられない。これが「1本のエラーレールに乗り続ける」というROPの本質を型で強制している部分
もし途中にnumberToLabel(n: number): Result<string, string>のような関数を挟んだら、Uはstringになり、以降のチェーンはResult<string, string>に切り替わります。Uが「一つ先の型」を担っているおかげで、成功値の型が途中で変わっても安全にチェーンし続けられます。
参考
まとめ
-
try-catchは関数シグネチャに失敗が現れないが、Result型なら型として残せる - ROPは「失敗したら以降をスキップして流す」という配線を
andThenに任せることで、ボイラープレートを減らす設計パターン - 一方で非同期処理との相性やデバッグのしにくさなど、トレードオフもある
-
andThenをメソッド化してチェーン可能にすると可読性が上がる。その際Uをメソッドスコープのジェネリクスにすることで、成功値の型が変わり続けるチェーンにも対応できる
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼