成功と失敗を明確にする結果オブジェクトパターン - コードで意図を伝えるエラーハンドリングの実践
はじめに:コードの「結果」が伝える意図
ソフトウェア開発において、関数やメソッドの実行結果は、単なる戻り値だけでは語り尽くせない情報を含んでいる場合があります。特に、処理が成功した場合の値と、何らかの理由で失敗した場合のエラー情報です。これらの「結果」をコード上でどのように表現するかは、そのコードが何を意図しているのか、呼び出し側がどのように応答すべきなのかを伝える上で非常に重要です。
従来の一般的な手法としては、成功時には正常な値を返し、失敗時には特定の戻り値(例: null, -1, 空のコレクション)や例外をスローするといった方法があります。しかし、これらの方法だけでは、コードの意図が曖昧になったり、エラー処理の漏れを引き起こしたりする可能性があります。
この記事では、成功と失敗の両方の可能性を明示的に表現するための「結果オブジェクトパターン」に焦点を当て、これがコードの意図をどのように伝えるのか、具体的なコード例を交えて解説します。
問題提起:従来の戻り値と例外だけでは伝わりにくい意図
関数の実行結果を伝える際、以下のような課題に直面することがあります。
-
戻り値によるエラー通知の曖昧さ:
- エラーを示すためにnullや特定の値を返す場合、呼び出し側はその値がエラーを示すことを「知っている」必要があります。ドキュメントや規約に依存しがちで、コードだけでは意図が読み取りにくいことがあります。
- 正常な戻り値の型がnullableでない場合でも、エラー時にnullを返すといった規約は、型の安全性と矛盾する可能性があります。
- 呼び出し側がnullチェックや特定の値のチェックを忘れると、意図しないバグにつながります。
```typescript // Before: 戻り値でエラーを通知(nullを返す) function findUserById(id: string): User | null { // ユーザーが見つかれば User オブジェクトを返す // 見つからなければ null を返す if (id === "unknown") { return null; } return { id: id, name: "Test User" }; // 実際にはDBなどから取得 }
// 呼び出し側 const user = findUserById("some-id"); // nullチェックを忘れる可能性がある // user.name; // nullの場合、実行時エラーになる可能性 ```
-
例外によるエラー通知の限界:
- 例外は「例外的な状況」を示すために使用されるべき、という考え方があります。しかし、「ファイルが見つからない」「ユーザーが見つからない」といった状況が、ビジネスロジック上起こりうる通常の結果である場合、これを例外で表現すると、コードのフローが読みにくくなることがあります。
- チェック例外(Checked Exception)を持たない言語では、呼び出し側がどのような例外が発生しうるかを知るのが難しく、例外処理の漏れにつながりやすいです。
- 例外処理(try-catch)は、通常の処理フローとは異なる記述になりがちで、コードの可読性を損なうことがあります。
```typescript // Before: 例外でエラーを通知 class UserNotFoundException extends Error {}
function findUserByIdThrows(id: string): User { if (id === "unknown") { throw new UserNotFoundException(
User with id ${id} not found.
); } return { id: id, name: "Test User" }; }// 呼び出し側 try { const user = findUserByIdThrows("some-id"); // ユーザーが見つかった場合の処理 } catch (e) { if (e instanceof UserNotFoundException) { // ユーザーが見つからなかった場合の処理 } else { // その他の例外処理 throw e; // または別の処理 } } // 正常系と異常系が try-catch で分断され、フローが読みにくくなる場合がある ```
これらの課題に対し、結果オブジェクトパターンは、関数が返しうる全ての可能性(成功時の値と失敗時のエラー情報)を単一の型で表現することで、コードの意図をより明確に伝えるアプローチを提供します。
解決策:結果オブジェクトパターンによる意図の明確化
結果オブジェクトパターンでは、関数の戻り値として、成功状態と失敗状態のどちらか一方を表すオブジェクトを返します。このオブジェクトは、成功時には成功した値を含み、失敗時にはエラー情報を含みます。
概念としては、以下のような型で表現できます。
// 概念的な Result 型の定義例
// これはあくまで概念であり、実際の言語ではクラスや構造体で実現します。
type Result<T, E> = Success<T> | Failure<E>;
interface Success<T> {
isSuccess: true;
value: T;
}
interface Failure<E> {
isSuccess: false;
error: E;
}
// ファクトリ関数(ヘルパー関数)があると便利です
function success<T, E>(value: T): Result<T, E> {
return { isSuccess: true, value: value } as Success<T>;
}
function failure<T, E>(error: E): Result<T, E> {
return { isSuccess: false, error: error } as Failure<E>;
}
このResult<T, E>
型は、「成功時には型T
の値を含むか、失敗時には型E
のエラー情報を含む」という関数の意図を、戻り値の型そのもので明確に表現します。呼び出し側は、このResult
オブジェクトを受け取った際に、必ず成功か失敗かを判定し、それぞれのケースに応じた処理を記述する必要があります。これにより、エラー処理の漏れを防ぎ、コードの意図(この関数は失敗する可能性がある)を強制的に呼び出し側に伝達できます。
Before/After:結果オブジェクトパターンを適用したコード
先ほどのfindUserById
関数に結果オブジェクトパターンを適用してみましょう。
// After: 結果オブジェクトパターンを適用
class UserNotFoundException extends Error {} // エラー情報として使用
type FindUserResult = Result<User, UserNotFoundException>; // ユーザーが見つかるか、見つからないエラー
function findUserByIdResult(id: string): FindUserResult {
if (id === "unknown") {
// 失敗の場合、failureファクトリ関数で Failure オブジェクトを返す
return failure(new UserNotFoundException(`User with id ${id} not found.`));
}
// 成功の場合、successファクトリ関数で Success オブジェクトを返す
return success({ id: id, name: "Test User" });
}
// 呼び出し側
const result = findUserByIdResult("some-id");
// Result オブジェクトのメソッドやプロパティを使って、成功/失敗をチェックし、値やエラーを取り出す
if (result.isSuccess) {
// 成功した場合の処理
const user: User = result.value; // 成功時は value プロパティにアクセス
console.log(`User found: ${user.name}`);
} else {
// 失敗した場合の処理
const error: UserNotFoundException = result.error; // 失敗時は error プロパティにアクセス
console.error(`Error finding user: ${error.message}`);
}
// エラーケースの呼び出し例
const errorResult = findUserByIdResult("unknown");
if (errorResult.isSuccess) {
console.log(`Unexpectedly found user: ${errorResult.value.name}`);
} else {
console.error(`Correctly handled error: ${errorResult.error.message}`);
}
このAfterのコードでは、findUserByIdResult
関数の戻り値の型がFindUserResult
、すなわちResult<User, UserNotFoundException>
であることが明確です。これにより、呼び出し側はコードの型情報を見ただけで、「この関数はUser
オブジェクトを返すか、UserNotFoundException
を含むエラーを返す可能性がある」という意図を理解できます。
呼び出し側では、result.isSuccess
のようなプロパティや、map
, flatMap
, orElse
といったヘルパーメソッド(実際のResult型実装ではしばしば提供されます)を使って、成功時と失敗時の処理を明示的に記述する必要があります。これにより、エラー処理の漏れが構造的に防がれます。
結果オブジェクトパターンがコードの意図伝達にもたらす効果
結果オブジェクトパターンを採用することで、以下の点でコードの意図伝達が改善されます。
- 発生しうる「結果」の明示化: 関数やメソッドのシグネチャ(特に戻り値の型)が、成功時の値だけでなく、失敗時のエラー情報も含めた「結果」の可能性全体を表現します。
- 呼び出し側への責任の委譲と強制: 呼び出し側は、返された結果オブジェクトの成功・失敗状態を必ず確認し、両方のケースに対する処理を記述する必要があります。これにより、「エラーを無視する」といった意図しない挙動を防ぎます。
- 処理フローの明確化: 成功時と失敗時の処理が、Resultオブジェクトに対する操作として同じレベルで記述されることが多く、コードの実行フローが追いやすくなります。
- エラー情報の構造化: 失敗時に返すエラーオブジェクトに、エラーコード、ユーザー向けメッセージ、開発者向け詳細、元の例外などの構造化された情報を含めることで、エラーの原因特定や対応が容易になります。
このパターンは、特に「失敗がビジネスロジックの一部として頻繁に発生しうる」ような場面(例: ユーザー入力の検証、外部API呼び出し、ファイル操作、DBアクセスなど)で有効です。
パターン導入における考慮事項
結果オブジェクトパターンは強力ですが、全ての場面で例外を完全に置き換えるものではありません。
- 例外の適切な使用: プログラムが継続不可能な致命的なエラー(例: メモリ不足、予期しないシステムエラー)に対しては、例外をスローすることが依然として適切な場合があります。結果オブジェクトは、呼び出し側が回復したり代替手段をとったりできる可能性のある「予期される失敗」に適しています。
- 冗長性: 成功・失敗のチェックと値/エラーの取り出しが必要になるため、シンプルなgetterなど、決して失敗しない関数に適用するとコードが冗長になる可能性があります。適切なバランスが重要です。
- 既存コードへの導入コスト: 既存のコードベースにResultパターンを導入する場合、関連する多くの関数のシグネチャと呼び出し側コードを変更する必要があり、コストがかかる可能性があります。
- 言語サポート: 一部の言語(Rust, Kotlinなど)はResult型やそれに類するものを標準で提供しており、非常に使いやすいです。TypeScriptのような言語でも型システムを活用して効果的に実現できますが、言語自体のサポートがない場合は、独自にResultクラス/型とヘルパー関数を実装する必要があります。
まとめ
コードは、単に処理を実行するだけでなく、その背後にある開発者の意図を伝える重要な手段です。特に、関数やメソッドの実行結果に成功と失敗の両方の可能性がある場合、その「結果」をどのように表現するかは、コードの可読性、保守性、そして正しさそのものに大きく影響します。
結果オブジェクトパターンは、関数の戻り値の型を通じて、発生しうる全ての可能性(成功時の値と失敗時のエラー情報)を明示的に表現することで、コードの意図を強力に伝達します。これにより、呼び出し側はエラー処理の責任を意識せざるを得なくなり、処理の漏れを防ぎ、より予測可能で堅牢なコードを書くことができるようになります。
あなたのコードでエラーや「失敗の可能性のある結果」をどのように扱っているか、ぜひ一度立ち止まって考えてみてください。結果オブジェクトパターンが、コードにさらなる「意味」を与えるための有効な選択肢となるかもしれません。