この記事はこの記事は、LIFULL Advent Calendar 2025 の記事になります。
また、 TypeScriptで『関数型ドメインモデリング』をやってみよう の続編になります。
はじめに
昨年は『型によるドメインモデリング』について解説しました。
今年は、関数型ドメインモデリングにおけるエラーハンドリングについてみていきましょう。
エラーハンドリングは、ソフトウェア開発において避けては通れない課題です。従来のtry-catchを使った例外処理は、コードの可読性を下げ、どこでエラーが発生するかを追跡しづらくなりがちです。
関数型ドメインモデリングにおけるRailway Oriented Programming(ROP)は、このようなエラーハンドリングの問題を関数型プログラミングのアプローチで解決する設計パターンです。基本的にはResult型と言われる型を使って「線路のように」処理の流れを表現していきます。
Result型を使うことで、例外の投げっぱなしを避け、型安全にわかりやすくエラー処理を記述することができます。
この記事では、Railway Oriented Programmingの概念と、TypeScriptでの実装方法を解説します。
Railway Oriented Programmingとは?
先述した通り、Railway Oriented Programming(以降ROP)は、処理の流れを線路に例えたプログラミングパターンです。
Result型を導入することで、線路の道のように表現することができます。
- 成功した場合: 正常に処理が進む道
- 失敗した場合: エラーが発生した後の道
処理が成功すれば成功の線路を進み続け、どこかでエラーが発生すれば失敗の線路に切り替わります。一度失敗の線路に入ると、その後の処理はスキップされ、最終的にエラーが返されます。
イメージ図
try-catch, ROPの処理の流れをそれぞれ図示すると以下の様になります。
通常の処理(try-catchの場合):
Input ──> [処理1] ──> [処理2] ──> [処理3] ──> Output
↓ ↓ ↓
Error Error Error
↓ ↓ ↓
[エラーハンドリング] ←─────┘
Railway Oriented Programming:
成功の線路(Success Track)
Input ════[処理1]════[処理2]════[処理3]════> Success Output
║ ║ ║
║ ║ ║ (エラー時に線路を切り替え)
╚══════════╩══════════╩═════════> Failure Output
失敗の線路(Failure Track)
従来のエラーハンドリングの課題
まず、従来のtry-catchを使った実装を見てみましょう。
function processOrder(orderId: number): void {
try {
const order = fetchOrder(orderId);
if (!order) {
throw new Error("Order not found");
}
const validatedOrder = validateOrder(order);
if (!validatedOrder) {
throw new Error("Invalid order");
}
const payment = processPayment(validatedOrder);
if (!payment) {
throw new Error("Payment failed");
}
sendConfirmationEmail(validatedOrder, payment);
} catch (error) {
console.error("Order processing failed:", error);
throw error;
}
}
この実装には以下のような問題があります:
- エラーの種類が型として表現されていない - どんなエラーが発生するか、型情報から分からない
- ネストが深くなりがち - 各ステップでのエラーチェックが必要
- エラーハンドリングのロジックが散在 - try-catchブロックが複数必要になることも
- 副作用の制御が難しい - 例外が飛ぶことで予期しない状態になる可能性
Result型の導入
Railway Oriented Programmingの基礎となるのがResult型です。Result型は、成功と失敗の両方の可能性を型として表現します。
TypeScriptでのResult型の実装例:
// Result型の定義
type Result<T, E> = Success<T> | Failure<E>;
type Success<T> = {
readonly type: 'success';
readonly value: T;
};
type Failure<E> = {
readonly type: 'failure';
readonly error: E;
};
// ヘルパー関数
function success<T>(value: T): Success<T> {
return { type: 'success', value };
}
function failure<E>(error: E): Failure<E> {
return { type: 'failure', error };
}
// 判定関数
function isSuccess<T, E>(result: Result<T, E>): result is Success<T> {
return result.type === 'success';
}
function isFailure<T, E>(result: Result<T, E>): result is Failure<E> {
return result.type === 'failure';
}
Railway Oriented Programmingの実装
基本的な関数の変換
まず、通常の関数をResult型を返す関数に変換してみましょう。
// エラー型の定義
type OrderError =
| { type: 'OrderNotFound'; orderId: number }
| { type: 'InvalidOrder'; reason: string }
| { type: 'PaymentFailed'; reason: string }
| { type: 'EmailSendFailed'; reason: string };
// ドメインモデル
type Order = {
id: number;
customerId: number;
amount: number;
};
type ValidatedOrder = Order & {
validated: true;
};
type Payment = {
orderId: number;
transactionId: string;
amount: number;
};
// Result型を返す関数群
function fetchOrder(orderId: number): Result<Order, OrderError> {
// 実際の実装では外部APIやDBにアクセス
const order = findOrderInDatabase(orderId);
if (!order) {
return failure({ type: 'OrderNotFound', orderId });
}
return success(order);
}
function validateOrder(order: Order): Result<ValidatedOrder, OrderError> {
if (order.amount <= 0) {
return failure({
type: 'InvalidOrder',
reason: 'Amount must be positive'
});
}
if (!order.customerId) {
return failure({
type: 'InvalidOrder',
reason: 'Customer ID is required'
});
}
return success({ ...order, validated: true });
}
function processPayment(order: ValidatedOrder): Result<Payment, OrderError> {
// 実際の決済処理
const paymentResult = chargeCustomer(order);
if (!paymentResult.success) {
return failure({
type: 'PaymentFailed',
reason: paymentResult.error
});
}
return success({
orderId: order.id,
transactionId: paymentResult.transactionId,
amount: order.amount,
});
}
function sendConfirmationEmail(
order: ValidatedOrder,
payment: Payment
): Result<void, OrderError> {
const emailResult = sendEmail(order.customerId, payment);
if (!emailResult.success) {
return failure({
type: 'EmailSendFailed',
reason: emailResult.error
});
}
return success(undefined);
}
bind関数(flatMap)の実装
次に、Result型を返す関数を連結するためのbind関数を実装します。この関数は、成功の場合は次の処理を実行し、失敗の場合はエラーをそのまま伝播させます。
function bind<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
if (isFailure(result)) {
return result;
}
return fn(result.value);
}
このbind関数が、Railway Oriented Programmingの分岐点の役割を果たします。成功の線路から失敗の線路への切り替えを自動的に行ってくれるのです。
bind関数の動作イメージ
bind関数の動作(成功の場合):
Result<T> ───┐
│
┌────▼────┐
│ bind │
│ fn │
└────┬────┘
│
Result<U>
bind関数の動作(失敗の場合):
Error<E> ────┐
│ (fnは実行されない)
┌────▼────┐
│ bind │
│ fn │ (スキップ)
└────┬────┘
│
Error<E> (そのまま通過)
複数のbindを連結した場合:
Success<A>
│
▼
┌────────┐ Success<B>
│ bind1 ├─────────┐
└───┬────┘ │
│ Fail ▼
│ ┌────────┐ Success<C>
│ │ bind2 ├─────────┐
│ └───┬────┘ │
│ │ Fail ▼
│ │ ┌────────┐
│ │ │ bind3 │
│ │ └───┬────┘
│ │ │
▼ ▼ ▼
Error<E> ════════════════════ Success<D>
(即座に返る) or Error<E>
map関数の実装
通常の関数(Result型を返さない関数)をResultにmapして、Railway上で使うためのmap関数も実装します。
function map<T, U, E>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> {
if (isFailure(result)) {
return result;
}
return success(fn(result.value));
}
map関数の動作イメージ
map: 通常の関数を線路上で使う
(T -> U の関数を Result<T> -> Result<U> に変換)
Success<T>
│
▼
┌────────┐
│ map │
│ (fn) │ fn: T -> U
└────┬───┘
│
Success<U>
実際の使用例
これらの関数を使って、注文処理のワークフローを実装してみましょう。
function processOrderWorkflow(orderId: number): Result<void, OrderError> {
// Railway Oriented Programmingによる実装
return bind(
fetchOrder(orderId),
(order) => bind(
validateOrder(order),
(validatedOrder) => bind(
processPayment(validatedOrder),
(payment) => sendConfirmationEmail(validatedOrder, payment)
)
)
);
}
// 使用例
const result = processOrderWorkflow(12345);
if (isSuccess(result)) {
console.log("Order processed successfully!");
} else {
// エラーの種類に応じた処理
switch (result.error.type) {
case 'OrderNotFound':
console.error(`Order ${result.error.orderId} not found`);
break;
case 'InvalidOrder':
console.error(`Invalid order: ${result.error.reason}`);
break;
case 'PaymentFailed':
console.error(`Payment failed: ${result.error.reason}`);
break;
case 'EmailSendFailed':
console.error(`Email send failed: ${result.error.reason}`);
break;
}
}
Railway Oriented Programmingの利点
1. 型安全性の向上
全てのエラーが型として表現されるため、コンパイル時にエラーハンドリングの漏れを検出できます。
// エラーの種類がすべて型で表現されている
type OrderError =
| { type: 'OrderNotFound'; orderId: number }
| { type: 'InvalidOrder'; reason: string }
| { type: 'PaymentFailed'; reason: string }
| { type: 'EmailSendFailed'; reason: string };
// どのエラーが発生するか、型を見ればわかる
function processOrder(id: number): Result<void, OrderError> {
// ...
}
2. エラー処理の統一
全ての関数が同じResult型を返すため、エラー処理のパターンが統一されます。
// すべて同じパターンでエラーハンドリングできる
const result1 = fetchOrder(1);
const result2 = validateOrder(order);
const result3 = processPayment(validatedOrder);
// 一貫した方法で結果を扱える
[result1, result2, result3].forEach(result => {
if (isFailure(result)) {
handleError(result.error);
}
});
3. 合成可能性
小さな関数を組み合わせて、複雑な処理を構築できます。
const workflow = (orderId: number) => {
return bind(
fetchOrder(orderId),
(order) => bind(
validateOrder(order),
(validatedOrder) => bind(
processPayment(validatedOrder),
(payment) => sendConfirmation(validatedOrder, payment)
)
)
)
}
pipe関数を実装すればもっとシンプルに実装することもできます。
function pipe<T, E>(...fns: Array<(arg: any) => Result<any, E>>) {
return (initial: Result<T, E>): Result<any, E> => {
return fns.reduce((result, fn) => bind(result, fn), initial);
};
}
const workflow = pipe(
fetchOrder,
validateOrder,
processPayment,
sendConfirmation
);
4. テスタビリティの向上
各関数が純粋関数として実装されるため、ユニットテストが書きやすくなります。
// テストが簡単
describe('validateOrder', () => {
it('should return failure for negative amount', () => {
const order: Order = { id: 1, customerId: 100, amount: -10 };
const result = validateOrder(order);
expect(isFailure(result)).toBe(true);
if (isFailure(result)) {
expect(result.error.type).toBe('InvalidOrder');
}
});
});
5. エラーの伝播が明示的
例外とは異なり、エラーがどのように伝播するかがコードから明確にわかります。
// エラーの流れが追いやすい
function workflow(id: number): Result<Payment, OrderError> {
const orderResult = fetchOrder(id);
if (isFailure(orderResult)) {
// ここで処理が止まることが明示的
return orderResult;
}
const validationResult = validateOrder(orderResult.value);
if (isFailure(validationResult)) {
// エラーがそのまま返される
return validationResult;
}
return processPayment(validationResult.value);
}
処理の流れは以下のようになります。
processOrderWorkflow の実行フロー:
orderId
│
├─> fetchOrder ────┬─> Order
│ │
└─> OrderNotFound │
├─> validateOrder ──┬─> ValidatedOrder
│ │
└─> InvalidOrder │
├─> processPayment ──┬─> Payment
│ │
└─> PaymentFailed │
├─> sendEmail ──┬─> Success(void)
│ │
└─> EmailFailed └─> Failure(Error)
Railway Oriented Programmingのデメリットと対策
デメリット1: 慣れが必要
Result型等の関数型プログラミングの概念の理解が必要なので、チームでの学習・慣れが必要なのはデメリットになりそうです。
デメリット2: ネストが深くなる場合がある
bindを多用すると、ネストが深くなることがあります。
対策として、fp-tsやeffectを使ったり、pipe関数を実装するという選択肢があります。
デメリット3: TypeScriptの型推論が複雑になる
ジェネリック型を多く扱うため、型推論が難しくなることがあります。
まとめ
Railway Oriented Programmingは、以下のような特徴を持つエラーハンドリングのパターンです:
- Result型によって成功と失敗を明示的に表現
- 型安全性が高く、コンパイル時にエラーを検出
- エラー処理のパターンが統一され、可読性が向上
- 関数の合成が容易で、再利用性が高い
- 純粋関数として実装できるため、テストが容易
従来のtry-catchによるエラーハンドリングと比べて、より宣言的で、型安全で、保守性の高いコードを実現できます。
ただし、関数型プログラミングの概念に慣れが必要なため、チームの状況やプロジェクトの性質を考慮して導入を検討するのが良いと思います。
参考資料
- 関数型ドメインモデリング
- Railway Oriented Programming
- What I have learned from 15 years of functional programming of FP 2025
図やサンプルコードはAIに書いてもらいました。