鉄道指向プログラミング(Railway Oriented Programming、ROP)は、処理の結果を「成功」と「失敗」の2本のレールに分け、一度失敗したら後続の処理を飛ばして失敗のまま終点まで運ぶエラー処理の組み立て方です。F#の解説サイト「F# for Fun and Profit」のScott Wlaschinが2013年5月11日の記事で名付け、2014年の講演で広まりました。部品になるのは、成功値かエラー値のどちらか一方を持つResult型です。この記事では、if文の連鎖をROPに書き換える前後のコード、TypeScript・Python・Rust・F#・C#で実行して出力まで確かめた実装、そして提唱者自身が2019年に書いた「Resultを使うべきでない場面」までを順に扱います。
まとめ:鉄道指向プログラミングの要点
- 仕組み:失敗しうる処理を「入力1本・出力2本(成功/失敗)」の関数にそろえ、
bindでつなぐ。失敗した時点で以降の関数は呼ばれない。 - つなぐ関数:失敗しうる処理は
bind(andThen)、失敗しない変換はmap、値を変えない副作用はtee、例外を投げる関数はtryCatchでレールに載せる。 - 言語:RustとF#とSwiftは標準のResult型、TypeScriptはneverthrow、Pythonはreturns、C#はCSharpFunctionalExtensions、JavaはVavrの
Eitherを使う。 - 例外との分担:業務上起こりうる失敗(在庫切れ・入力不正)はResultに載せ、メモリ不足やバグのような想定外は例外で止める。提唱者の分類では前者がDomain Error、後者がPanic。
- 使わない場面:スタックトレースが要る、すぐ止めるべき、誰もエラーの種類を見ない、性能が厳しい、ROPを知らない呼び出し側に公開するAPI。提唱者本人がこれらを「Resultを使うな」と挙げている。
鉄道指向プログラミングの仕組み|2本のレールとスイッチ関数
名前は鉄道の分岐器(ポイント)から来ています。各処理を、入力が1本で出力が「成功」「失敗」の2本に分かれる関数として作ります。原典ではこれをスイッチ関数と呼びます。成功なら本線(成功レール)を進んで次のスイッチへ入り、失敗なら側線(失敗レール)へ移って、以降のスイッチを素通りしたまま終点に着きます。ここで説明するbind中心の直列処理では復帰しませんが、Rustのor_elseなどを明示的に使えば、失敗から成功へ復帰する処理も組み込めます。
手続き的に書くと、各ステップの直後に「失敗ならreturn」を挟むことになり、正常系の処理がエラー確認の間に散らばります。ROPでは失敗の素通りをbindが引き受けるため、呼び出し側には正常系の並びだけが残ります。
原典:2013年の記事と2014年の講演
最初の記事は連載「A recipe for a functional app」の第2回で、日付は2013年5月11日です。その後Wlaschinは講演「Railway Oriented Programming」をNDC London 2014、NDC Oslo 2014、Functional Programming eXchange(スライドは2014年3月14日付)で行い、スライドと動画を公開しています。
同じページでWlaschinは、この手法を自分が発明したとは主張しておらず、自分のものは「ばかげた比喩」だけだと書いています。HaskellのEither型とbind(>>=)、Kleisli合成(>=>)で表せる内容を、モナドの用語を使わずに図で説明したのがROPです。マイクロソフトのF#のResult型のドキュメントも、Result型を使ったモナド的なエラー処理がF#コミュニティでRailway-oriented Programmingと呼ばれることが多い、と説明しています。同じ著者の書籍『Domain Modeling Made Functional』(日本語版『関数型ドメインモデリング』、猪股健太郎訳、アスキードワンゴ、2024年6月28日発売)でも扱われていますが、書籍では米国の出版社に合わせて「Railroad Oriented Programming」と表記されており、著者は日本の読者の質問にどちらの表記でもよいと答えています。
レールをつなぐ関数|bind・map・tee・tryCatchの役割
ROPで覚える関数は、つなぎたい関数の「形」で決まります。原典は次の道具をそろえて「どの関数もレールに載せられる」ようにしています。
| 原典の名前 | つなぐ関数の形 | neverthrow | Rust | returns |
|---|---|---|---|---|
bind |
値 → Result(失敗しうる) | andThen |
and_then/? |
bind |
map |
値 → 値(失敗しない) | map |
map |
map_ |
tee |
値 → なし(ログ・通知) | andTee |
inspect |
map_(tap(…)) |
tryCatch |
例外を投げる関数 | fromThrowable |
(例外なし) | @safe |
doubleMap |
成功・失敗の両方を変換 | map+mapErr |
map+map_err |
map_+alt |
迷いやすいのはbindとmapの取り違えです。戻り値がResultの関数をmapでつなぐと、Resultの中にResultが入った二重の箱になります。「渡す関数が失敗しうるならbind、必ず成功するならmap」と覚えれば足ります。teeはUNIXのteeコマンドから取った名前で、戻り値を使わない副作用を、元の値を保持したままレールに挟みます。ただしneverthrowのandTeeは副作用の失敗を結果に反映しません。DB更新やメール送信の失敗も呼び出し側に返す場合は、Resultを返す関数にしてandThrough(失敗したらそのエラーを流し、成功なら元の値を通す)かandThenでつなぎます。
if文の連鎖からROPへの書き換え(TypeScript・neverthrow 8.2.0)
会員登録を例にします。手順は「メール形式の確認 → 年齢の確認 → メールの正規化 → DB保存 → 登録メール送信」です。前の2つと保存は失敗しうる処理、正規化は失敗しない変換、メール送信は結果を返さない副作用として扱います。この例の形式確認は「@」の有無だけを調べる簡易判定で、保存は固定IDを返す模擬処理、送信はログ出力です。
書き換え前:途中でreturnするif文とtry-catch
type Input = { email: string; age: number };
async function signupBefore(i: Input): Promise<string> {
if (!i.email.includes("@")) {
return "NG: メール形式が不正";
}
if (i.age < 18) {
return "NG: 18歳未満";
}
const email = i.email.trim().toLowerCase();
try {
const id = await saveUser(email); // DB保存(失敗すると例外)
console.log(`mail to user ${id}`);
return `OK id=${id}`;
} catch (e) {
return `NG: 保存失敗 ${(e as Error).message}`;
}
}
async function saveUser(email: string): Promise<number> {
if (email.endsWith("@fail.example")) throw new Error("timeout");
return 42;
}
console.log(await signupBefore({ email: " [email protected]", age: 20 }));
console.log(await signupBefore({ email: "[email protected]", age: 30 }));
この形でも動きますが、失敗の理由が文字列に埋もれ、呼び出し側は戻り値の型(Promise<string>)から失敗の種類を知ることができません。確認を1つ足すたびにifとreturnが1組増えます。
書き換え後:andThen・map・andTeeによる処理の連結
import { ok, err, Result, ResultAsync } from "neverthrow";
type Input = { email: string; age: number };
type SignupError =
| { type: "InvalidEmail"; email: string }
| { type: "TooYoung"; age: number }
| { type: "DbError"; message: string };
const validateEmail = (i: Input): Result<Input, SignupError> =>
i.email.includes("@") ? ok(i) : err({ type: "InvalidEmail", email: i.email });
const checkAge = (i: Input): Result<Input, SignupError> =>
i.age >= 18 ? ok(i) : err({ type: "TooYoung", age: i.age });
const normalize = (i: Input): Input => ({ ...i, email: i.email.trim().toLowerCase() });
const save = (i: Input): ResultAsync<number, SignupError> =>
ResultAsync.fromPromise(saveUser(i.email), (e) => ({
type: "DbError",
message: (e as Error).message,
}));
const signup = (i: Input) =>
validateEmail(i)
.andThen(checkAge) // 失敗しうる処理をつなぐ(bind)
.map(normalize) // 失敗しない変換(map)
.asyncAndThen(save) // 非同期で失敗しうる処理
.andTee((id) => console.log(`mail to user ${id}`)); // 値を変えない副作用(tee)
async function saveUser(email: string): Promise<number> {
if (email.endsWith("@fail.example")) throw new Error("timeout");
return 42;
}
for (const i of [
{ email: " [email protected]", age: 20 },
{ email: "taro.example.com", age: 20 },
{ email: "[email protected]", age: 15 },
{ email: "[email protected]", age: 30 },
]) {
const r = await signup(i);
console.log(r.match((id) => `OK id=${id}`, (e) => `NG ${JSON.stringify(e)}`));
}
Node.js v26.5.0・TypeScript 7.0.2で型検査して実行した出力です。再現時はneverthrow 8.2.0を導入し、package.jsonのtypeをmodule、tsconfig.jsonのtargetをes2022、moduleとmoduleResolutionをnodenextに設定して、各例を別ファイルでコンパイルしてください。
mail to user 42
OK id=42
NG {"type":"InvalidEmail","email":"taro.example.com"}
NG {"type":"TooYoung","age":15}
NG {"type":"DbError","message":"timeout"}
メール形式で失敗した2件目は、年齢確認も保存もメール送信も呼ばれていません。signupの本体は手順の並びだけになり、失敗の種類はSignupErrorというユニオン型として戻り値の型に出ます。ユニオン型で失敗を列挙する書き方はDiscriminated Unionとは?判別可能なユニオン型の書き方とneverでの網羅チェックで詳しく扱っています。neverthrowの最新版は8.2.0(2025年2月21日公開)で、npmの週間ダウンロードは約401万件(2026年10月21〜27日)です。
TypeScriptのResult処理漏れと型検査の限界
Result型は「エラーを型に出す」仕組みですが、TypeScriptのコンパイラは戻り値のResultを使わずに捨てても何も言いません。RustはResultに#[must_use]が付いているため、捨てるとunused `Result` that must be usedという警告がrustc 1.99.1でも出ます。neverthrowの公式READMEは、この差を埋めるためにeslint-plugin-neverthrowの導入を推奨しています。ただし同プラグインのnpm上の最新版は1.1.4(2021年11月9日)で4年以上更新がないため、導入前に自分のESLintの版で動くかを確かめてください。
言語別のResult型と鉄道指向プログラミングの実装
Result型が標準にある言語と、ライブラリで足す言語に分かれます。版と日付は2026年10月29日に各レジストリで確認したものです。
| 言語 | Result型 | 最新版(公開日) | bind相当 |
|---|---|---|---|
| F# | 標準(F# 4.1で追加) | FsToolkit.ErrorHandling 5.2.0(2026-02-27) | Result.bind |
| Rust | 標準Result<T, E> |
言語機能 | and_then/? |
| Swift | 標準(Swift 5.0で追加) | 言語機能 | flatMap |
| TypeScript | neverthrow | 8.2.0(2025-02-21) | andThen |
| Python | returns | 0.30.0(2026-10-27) | bind |
| C# | CSharpFunctionalExtensions | 3.7.0(2026-03-02) | Bind |
| Java | Vavr Either |
1.1.0(2026-10-10) | flatMap |
| Kotlin | kotlin-result/Arrow | 2.3.1(2026-03-14)/2.3.0.1(2026-10-27) | andThen/flatMap |
Javaで長く使われてきたVavrは、2026年2月9日に1.0.0が出て1.x系に入りました。SwiftのResultは提案SE-0235でSwift 5.0の標準ライブラリに入っています。
Python:returns 0.30.0のflow・bind・@safe
Pythonではreturns(dry-python)が定番です。0.30.0は2026年10月27日公開で、Python 3.11以上が必要です。flowで値を左から右へ流し、bindで失敗しうる関数を、map_で失敗しない関数をつなぎます。例外を投げる既存の関数は@safeを付けるだけでResultを返すようになるので、例外前提のPythonにも一部分から入れられます。
from returns.result import Result, Success, Failure, safe
from returns.pipeline import flow, is_successful
from returns.pointfree import bind, map_, alt
def validate_email(email: str) -> Result[str, str]:
return Success(email) if "@" in email else Failure(f"メール形式が不正: {email}")
def check_domain(email: str) -> Result[str, str]:
return Failure("使い捨てドメイン") if email.endswith("@temp.example") else Success(email)
@safe
def parse_age(raw: str) -> int:
return int(raw) # 例外を投げる既存関数を Result に包む
def signup(email: str) -> Result[str, str]:
return flow(
email,
validate_email,
bind(check_domain),
map_(str.lower),
alt(lambda e: f"登録失敗: {e}"),
)
for e in ["[email protected]", "taro.example.com", "[email protected]"]:
print(signup(e))
print(parse_age("20"), parse_age("abc"))
print(is_successful(parse_age("abc")))
Python 3.12で実行した出力です。
<Success: [email protected]>
<Failure: 登録失敗: メール形式が不正: taro.example.com>
<Failure: 登録失敗: 使い捨てドメイン>
<Success: 20> <Failure: invalid literal for int() with base 10: 'abc'>
False
flowやbindの型をmypyで正しく推論させるには、READMEの指示どおりplugins = ["returns.contrib.mypy.returns_plugin"]を設定に足します。プラグインなしでも実行はできますが、型の誤りを検出できる範囲が狭くなります。
Rust:?演算子とFromによるエラー型の変換
Rustは言語にROPが組み込まれている例です。?演算子は、値がErrならその場で関数から返し、そのときFromトレイトでエラー型を変換します。下のコードではparse()が返すParseIntErrorが、impl Fromによって自前のSignupErrorに変わります。
use std::num::ParseIntError;
#[derive(Debug)]
enum SignupError {
BadAge(ParseIntError),
TooYoung(u32),
}
impl From<ParseIntError> for SignupError {
fn from(e: ParseIntError) -> Self {
SignupError::BadAge(e)
}
}
fn parse_age(s: &str) -> Result<u32, SignupError> {
let age: u32 = s.trim().parse()?; // ParseIntError は From で SignupError に変換される
Ok(age)
}
fn check_age(age: u32) -> Result<u32, SignupError> {
if age >= 18 { Ok(age) } else { Err(SignupError::TooYoung(age)) }
}
fn signup(s: &str) -> Result<String, String> {
parse_age(s)
.and_then(check_age) // bind
.map(|a| format!("{a}歳で登録")) // map
.map_err(|e| match e { // mapErr(列挙子の漏れはコンパイルエラー)
SignupError::BadAge(p) => format!("年齢が数値でない: {p}"),
SignupError::TooYoung(a) => format!("{a}歳は登録できない"),
})
}
fn main() {
for s in ["20", "abc", "15"] {
println!("{:?}", signup(s));
}
}
Ok("20歳で登録")
Err("年齢が数値でない: invalid digit found in string")
Err("15歳は登録できない")
map_errの中のmatchは、SignupErrorに列挙子を足したのに分岐を書き忘れるとコンパイルエラーになります。失敗の種類を型で列挙する利点がいちばんはっきり出る箇所です。
F#:標準のResultモジュールとFsToolkit.ErrorHandlingの計算式
F#のResult型はF# 4.1でFSharp.Coreに入り(RFC FS-1004)、ケース名はOkとErrorです。Result.bind・Result.map・Result.mapErrorをパイプでつなぐ書き方に加え、FsToolkit.ErrorHandlingのresult { }計算式を使うと、let!で成功値を取り出す手続き風の見た目で同じ処理が書けます。
#r "nuget: FsToolkit.ErrorHandling, 5.2.0"
open FsToolkit.ErrorHandling
type SignupError =
| InvalidEmail of string
| TooYoung of int
let validateEmail (email: string) =
if email.Contains "@" then Ok email else Error (InvalidEmail email)
let checkAge age =
if age >= 18 then Ok age else Error (TooYoung age)
// 標準の Result モジュールで連結
let signup1 email age =
validateEmail email
|> Result.bind (fun e -> checkAge age |> Result.map (fun a -> e, a))
|> Result.map (fun (e, a) -> $"{e.ToLower()} ({a})")
|> Result.mapError (fun err -> $"失敗: %A{err}")
// FsToolkit.ErrorHandling の計算式で同じ処理
let signup2 email age = result {
let! e = validateEmail email
let! a = checkAge age
return $"{e.ToLower()} ({a})"
}
printfn "%A" (signup1 "[email protected]" 20)
printfn "%A" (signup1 "taro" 20)
printfn "%A" (signup2 "taro" 15)
.NET SDK 10.0.401のdotnet fsi(F# 10)で実行した出力です。
Ok "[email protected] (20)"
Error "失敗: InvalidEmail "taro""
Error (InvalidEmail "taro")
C#:CSharpFunctionalExtensions 3.7.0のBind・Map・Tap
C#の標準ライブラリにResult型はありません。ROPの形で書くならCSharpFunctionalExtensionsが手早く、Bind・Map・MapErrorに加えて、teeにあたるTapと、最後に両レールを1つの値へ戻すMatchがあります。
using CSharpFunctionalExtensions;
Result<string> ValidateEmail(string email) =>
email.Contains('@') ? Result.Success(email) : Result.Failure<string>($"メール形式が不正: {email}");
Result<int> CheckAge(int age) =>
age >= 18 ? Result.Success(age) : Result.Failure<int>($"18歳未満: {age}");
string Signup(string email, int age) =>
ValidateEmail(email)
.Bind(e => CheckAge(age).Map(a => $"{e.ToLowerInvariant()} ({a})"))
.Tap(s => Console.WriteLine($"送信: {s}"))
.MapError(err => $"登録失敗: {err}")
.Match(ok => $"OK {ok}", ng => $"NG {ng}");
Console.WriteLine(Signup("[email protected]", 20));
Console.WriteLine(Signup("taro", 20));
Console.WriteLine(Signup("[email protected]", 15));
送信: [email protected] (20)
OK [email protected] (20)
NG 登録失敗: メール形式が不正: taro
NG 登録失敗: 18歳未満: 15
失敗側を文字列でなく型付きのエラーオブジェクトで持ちたい場合は、同じCSharpFunctionalExtensionsのResult<T, E>を使えます。別ライブラリのFluentResultsとの違いと移行手順はFluentResultsとは|C#のResult型エラーハンドリングとv4.0移行にまとめています。
全項目のエラーを集める入力チェック
ROPは最初の失敗で止まる1本道です。入力フォームのように「メールも年齢もだめ」を一度に返したい場面では、この性質が裏目に出ます。原典は並列につなぐ&&&(Combining functions in parallel)を別の道具として用意しており、ライブラリでは次のAPIが対応します。
import { ok, err, Result } from "neverthrow";
const checkEmail = (s: string): Result<string, string> =>
s.includes("@") ? ok(s) : err("メール形式が不正");
const checkAge = (n: number): Result<number, string> =>
n >= 18 ? ok(n) : err("18歳未満");
const first = Result.combine([checkEmail("bad"), checkAge(10)]);
const all = Result.combineWithAllErrors([checkEmail("bad"), checkAge(10)]);
console.log(first.isErr() && first.error); // 最初のエラーだけ
console.log(all.isErr() && all.error); // 全項目のエラー
メール形式が不正
[ 'メール形式が不正', '18歳未満' ]
Result.combineは最初のエラーだけを、Result.combineWithAllErrorsは全項目のエラーを配列で返します。F#ではFsToolkit.ErrorHandlingのvalidation { }計算式で、and!でつないだ確認がすべて実行されます(上のF#のvalidateEmail・checkAgeを使用)。
// 全エラーを集める(validation 計算式)
let signup3 email age = validation {
let! e = validateEmail email
and! a = checkAge age
return $"{e} ({a})"
}
printfn "%A" (signup3 "taro" 15)
Error [InvalidEmail "taro"; TooYoung 15]
使い分けは単純で、後の処理が前の結果に依存するならbindの直列、互いに独立した確認を並べるだけなら全エラー集約です。
例外(try-catch)との使い分け|提唱者の3分類
Wlaschinは2019年12月20日の記事で、著書『Domain Modeling Made Functional』(日本語版『関数型ドメインモデリング』)のエラー分類を引き、Resultを使うのは1つ目だけだと述べています。
| 分類 | 例 | 扱い |
|---|---|---|
| Domain Error | 請求で弾かれた注文・存在しない商品コード | Resultで型に出す |
| Panic | メモリ不足・ゼロ除算・null参照 | 例外で処理を捨て、最上位でログ |
| Infrastructure Error | ネットワークのタイムアウト・認証失敗 | 業務で扱うならResult、それ以外は例外 |
Domain Errorは業務側にすでに対応手順があるので、型として列挙し、処理漏れをコンパイル時に見つけたい対象です。Wlaschinはこの用途のResultを「情報付きの真偽値」と表現しています。Panicは状態が壊れているので、Resultで運ばずに例外で止めるのが正解です。Infrastructure Errorは判断が分かれ、迷ったら業務の担当者に「タイムアウトしたら業務としてどうするか」を聞いて決めます。
例外処理そのものの書き方と言語ごとの違いはエラーハンドリングとは?例外処理のベストプラクティスとtry-catch-finallyの言語別の違いを参照してください。Goのように例外を持たず戻り値でエラーを返す言語の作法はGo言語のエラーハンドリング実装ガイドで扱っています。
Resultを使うべきでない8つの場面
同じ記事でWlaschinは、ROPが「考えなしに使われている」として、Resultを使うべきでない理由を8つ挙げています。ROPはResultを返す関数をつなぐ配管にすぎない、というのが前提です。
| # | 使うべきでない場面 | 代わりの手段 |
|---|---|---|
| 1 | スタックトレースや発生位置が要る | 例外 |
| 2 | try-catchを作り直しているだけ | 言語の例外機構 |
| 3 | 続行できず即座に止めるべき | 例外・プロセス終了 |
| 4 | 外から見えない内部の複雑な制御 | 局所的な例外 |
| 5 | 呼び出し側がエラーの種類を気にしない | Option型 |
| 6 | あらゆるI/Oエラーを列挙しようとしている | 必要分だけResult、残りは例外 |
| 7 | 性能が厳しい箇所(計測で判明した場合) | 計測してから判断 |
| 8 | ROPを知らない呼び出し側に公開するAPI | 呼び出し側の言語の慣習 |
実務で効くのは4と6と8です。4は、木構造をたどって途中で抜けるような処理を外に見せないなら、局所的な例外で書いた方が読みやすいという指摘で、Wlaschinは数百行のマイクロサービスなら境界の外へ漏らさない限り例外で構わないとしています。6は、設定ファイルが開けないなら業務の失敗ではないので即座に止めるべきで、ワープロが文書を開けない場合とは扱いが違うという例です。8は、OO言語の利用者に関数型の慣習を押し付けないという意味で、場合によってはnullを返してもよいとまで書いています。
導入した側の報告も同じ方向です。KINTOテクノロジーズは2025年12月13日の技術ブログで、RustでROPを導入した管理システムについて、関数型に慣れていないメンバーの習得とレビューの負荷、mapとand_thenでつなぐぶんのコード量の増加、非同期処理で実行時に起きたstack overflowを課題に挙げ、規模や納期によっては別の方法を勧めています。チームの半数がResult型を初めて触る段階なら、まずDomain Errorを返す境界(ユースケース層の入口など)だけにROPを入れ、内側の実装は言語の慣習に合わせ、レビュー負荷を確認しながら適用範囲を広げることを提案します。
よくある質問
Result型とOption型(Maybe型)の違いは?
Option型は「値があるか無いか」だけを表し、無い理由を持ちません。Result型は失敗側にエラー値を持てます。Wlaschinの8理由の5番目のとおり、呼び出し側が失敗の理由を使わないならOption型で足ります。
プログラミングで「result」はどういう意味ですか?
一般には関数の戻り値や処理結果を指しますが、Rust・F#・SwiftなどのResult型は「成功値かエラー値のどちらか一方」を表す型です。ケース名は言語ごとにOk/Err(Rust)、Ok/Error(F#)、success/failure(Swift)、Success/Failure(Pythonのreturns)と異なります。
Pythonで鉄道指向プログラミングを書くには?
returns 0.30.0(Python 3.11以上)のSuccess・Failureで値を包み、flowとbind・map_でつなぎます。例外を投げる既存関数は@safeでResultに変えられます。型推論を効かせるにはmypyプラグインの設定が要ります。
JavaにResult型はありますか?
Javaの標準ライブラリにはありません。Vavr 1.1.0のEither(慣習でRightが成功、Leftが失敗)をflatMapでつなぐのが一般的です。Kotlinならkotlin-result 2.3.1のResultとArrowのEitherが使えます。
鉄道指向プログラミングとモナドの関係は?
中身はEitherモナドのbindとKleisli合成です。Wlaschinはモナドの用語を避けて図で説明するために鉄道の比喩を使ったと書いており、モナド則を知らなくてもbindとmapの使い分けを押さえれば実装できます。関数型の基本概念は関数型プログラミングとは?純粋関数・不変性・高階関数をTypeScriptの実例で解説を参照してください。