Upgrade to Pro — share decks privately, control downloads, hide ads and more …

try-catchを使わないエラーハンドリング!? PHPでResult型の考え方を取り入れてみよう

try-catchを使わないエラーハンドリング!? PHPでResult型の考え方を取り入れてみよう

PHPカンファレンス新潟2025の登壇資料です。
https://fortee.jp/phpconniigata-2025/proposal/de433e9c-4224-4fe8-9cba-4be9dba34084

PHPではtry-catchを使った例外処理が一般的ですが、「この例外はどのレイヤーで処理すればいいのか?」や「どの場面で例外を使うべきなのかが曖昧だ…」と感じたことはありませんか?
例外の種類や扱い方が曖昧だと、混乱しますよね。
この課題に対するヒントとして、Rustなどの言語で採用されているResult型の考え方があります。

Result型は、失敗が起こり得るということを型として扱い、例外に頼らずエラーを管理する手法です。
これにより、エラーの種類や処理責任が明確になります。

Avatar for Takuma Kajikawa

Takuma Kajikawa

May 31, 2025
Tweet

More Decks by Takuma Kajikawa

Other Decks in Programming

Transcript

  1. Result 型 try-catchとは別のエラーハンドリングのための型 戻り値として「 成功 」または「 失敗 」を持つ 成功 (Ok):

    正常な値 を返す 失敗 (Err): エラー情報 を返す PHPには標準では実装されていない Result型を返す処理 if ($input === null) { // 失敗の場合 return Result::err('Input cannot be null'); } else { // 成功の場合 return Result::ok($input); } 結果の検証と値の取得 if ($result->isOk()) { $value = $result->unwrap(); } else { $error = $result->unwrapErr(); } 6/34
  2. Databa DataLayer BusinessLayer ServiceLayer Client Databa DataLayer BusinessLayer ServiceLayer Client

    例外発⽣! 処理中断 処理中断 ⼤域脱出!深くネストした呼び出しから 直接上位レイヤーへジャンプ alt [ データベースエラー発⽣] [ 正常処理] リクエスト処理 ビジネスロジック実⾏ データ取得 クエリ実⾏ DB エラー 例外の伝播 例外の伝播 例外をキャッチ エラーレスポンス 結果 加⼯データ 処理結果 成功レスポンス 例外(try-catch) goto文のように処理の流れが突然変わる 関数のシグネチャから例外の発生が読み取れない 例外宣言の漏れや、catchの漏れが発生しやすい 7/34
  3. エラーの返し方の比較 try-catch PHPDocで何の例外が投げられるか記載 /** * @throws UserNotFoundException * @throws OutOfStockException

    */ public function placeOrder(OrderInput $input): Order { $user = $this->userRepository->findById($input->userId); if ($user === null) { throw new UserNotFoundException('ユーザーが存在しません'); } if (! $this->productRepository->isInStock($input->productId, $input->quantity)) { throw new OutOfStockException('在庫が不足しています'); } $order = new Order(); return $order; } Result型 戻り値の型にResultを指定 /** * @return Result<Order, OrderError> */ public function placeOrder(OrderInput $input): Result { $user = $this->userRepository->findById($input->userId); if ($user === null) { return Result::err(OrderError::UserNotFound); } if (! $this->productRepository->isInStock($input->productId, $input->quantity)) { return Result::err(OrderError::OutOfStock); } $order = new Order(); return Result::ok($order); } 8/34
  4. エラーハンドリングの比較 try-catch try { $order = $this->orderService->placeOrder($input); } catch (UserNotFoundException

    $e) { // ユーザーが見つからない場合の処理 } catch (OutOfStockException $e) { // 在庫切れの処理 } catch (GuestUserCannotOrderException $e) { // ゲストユーザーの注文処理 } catch (RestrictedProductIncludedException $e) { // 制限商品が含まれている場合の処理 } try-catch なくても動く $order = $this->orderService->placeOrder($input); Result型 unwrap() で成功した場合のみ値を取り出す $result = $this->orderService->placeOrder($input); if ($result->isErr()) { $error = $result->unwrapErr(); return match ($error) { OrderError::UserNotFound => // ユーザーが見つからない場合の処理 OrderError::OutOfStock => // 在庫切れの処理 OrderError::GuestUserCannotOrder => // ゲストユーザーの注文処理 OrderError::RestrictedProductIncluded => // 制限商品が含まれている場合の処理 }; } // 成功した場合は値を取り出す $order = $result->unwrap(); 9/34
  5. Databas DataLayer BusinessLayer ServiceLayer Client Databas DataLayer BusinessLayer ServiceLayer Client

    Result::err() を返す エラーチェック エラーチェック <b> 直線的な処理の流れ!</b> 戻り値として明⽰的にエラーを伝播 Result::ok() を返す alt [ データベースエラー発⽣] [ 正常処理] リクエスト処理 ビジネスロジック実⾏ データ取得 クエリ実⾏ エラー結果 "Result<T, E>" "Result<T, E>" エラーレスポンス 結果 "Result<T, E>" "Result<T, E>" 成功レスポンス Result型のメリット 関数のシグネチャから「この関数は失敗する可能性 がある」ことが明示的 呼び出し側は戻り値のチェックを強制される (静的解 析ツールは必要) 処理の流れが直線的 10/34
  6. Result型のクラス定義 基底クラスの定義 Ok(T) Err(E) Result 値: T エラー: E /**

    * @template T 成功時の値の型 * @template E 失敗時のエラーの型 */ abstract readonly class Result{} OkとErrそれぞれのクラスを定義 /** * @template T * @extends Result<T, never> */ final readonly class Ok extends Result { /** * @param T $value */ public function __construct( private mixed $value ) { } } /** * @template E * @extends Result<never, E> */ final readonly class Err extends Result { /** * @param E $value */ public function __construct( private mixed $value ) { } 12/34
  7. ジェネリクスを使おう ジェネリクスを使わない場合... Result型はそれぞれのケースで定義するか、 ユニオン型やmixed型を使うことになってしまう class Result { // Bad: Unionでひたすら定義

    public static function ok(User|Order|Product $value) { return new Ok($value); } // Bad: mixedで型安全性が失われる public static function err(mixed $error) { return new Err($error); } } // Bad 個別に定義 class UserResult { public static function ok(User $value) { return new Ok($value); } public static function err(UserError $error) { return new Err($error); } } 13/34
  8. PHPのジェネリクス @template PHPにはネイティブなジェネリクス機能がない PHPDocと静的解析ツールを組み合わせて実現 @template でテンプレート型を定義 @return でTemplate型を指定 /** *

    @template T 成功時の値の型 * @template E 失敗時のエラーの型 */ class Result {} /** * @return Result<User, UserNotFoundErr> */ function getUserById(int $userId): Result { } 14/34
  9. Result型のメソッド実装 基本操作メソッド isOk() 成功かどうかを確認 isErr() 失敗かどうかを確認 unwrap() 成功時の値を取得 unwrapErr() 失敗時のエラーを取得

    unwrapOr(default) 成功時の値またはデフォ ルト値を取得 関数型プログラミング用メソッド map(fn) 成功値に関数を適用 mapErr(fn) エラー値に関数を適用 andThen(fn) 成功時に別のResultを返す関数を 適用 orElse(fn) 失敗時に別のResultを返す関数を適 用 etc... interfaceはRustのResult型を参考に。 最初は、基本的なメソッドだけでもOK 15/34
  10. 基本的なメソッドの実装 Ok public function isOk(): bool { return true; }

    public function isErr(): bool { return false; } /** * @return T */ public function unwrap(string $message = ''): mixed { return $this->value; } public function unwrapErr(string $message = 'called Result::unwrapErr() on an Ok value'): never { throw new \RuntimeException($message); } Err public function isOk(): bool { return false; } public function isErr(): bool { return true; } public function unwrap(string $message = 'called Result::unwrap() on an Err value'): never { throw new \RuntimeException($message); } /** * @return E */ public function unwrapErr(string $message = ''): mixed { return $this->value; } 16/34
  11. 基本的にはこれでも十分 $result = $this->orderService->placeOrder($input); if ($result->isErr()) { $error = $result->unwrapErr();

    return match ($error) { OrderError::UserNotFound => // ユーザーが見つからない場合の処理 OrderError::OutOfStock => // 在庫切れの処理 OrderError::GuestUserCannotOrder => // ゲストユーザーの注文処理 OrderError::RestrictedProductIncluded => // 制限商品が含まれている場合の処理 }; } // 成功した場合は値を取り出す $order = $result->unwrap(); 17/34
  12. Result型を発展させていくと andThen(fn) 成功時に別のResultを返す関数を適用 $result = $this->placeOrder($input) ->andThen(fn($order) => $this->addPoint($order)) ->andThen(fn($order)

    => $this->notify($order)); if ($result->isErr()) { $error = $result->unwrapErr(); return response()->json([ 'error' => [ 'code' => $error->code(), 'message' => $error->message(), ] ], $error->statusCode()); } return response()->json(['message' => '注文完了!'], 201); 18/34
  13. バリューオブジェクト 不変条件違反(バリデーションミス) LogicExceptionを投げる ここで投げた例外はフレームワークのハンドラまで キャッチしない ドメインロジック ビジネスプロセスの結果表現 -> Result型 リポジトリ

    「見つからない」状態 -> Result型 アプリケーション ドメイン層のResult型を受け取り、Presentationに適 した例外を投げる ここで投げた例外はフレームワークのハンドラまで キャッチしない 26/34
  14. Result<T, E> 型のエラー型 E に何を指定するか? 1. Exceptionインスタンス 既存の例外ベースのコードとの互換性を保つ unwrap()時に例外を再度投げる実装も可能 2.

    カスタムのドメインエラー型 ドメイン層でビジネスロジックに特化する 失敗の種類をEnumで定義 失敗ケースをパターンマッチして処理を分岐できる enum UserError { NotFound, InsufficientBalance, InvalidInput, } $result = $userService->findById($userId); if ($result->isErr()) { // エラー処理 match ($result->unwrapErr()) { UserError::NotFound => $this->handleUserNotFound(), UserError::InsufficientBalance => $this->handleInsufficientBalance(), UserError::InvalidInput => $this->handleInvalidInput(), }; } 27/34
  15. 例外発生の可能性を明示する /** * ユーザーをIDで検索する * @param int $userId ユーザーID *

    @throws UserNotFoundException ユーザーが見つからない場合 */ public function findById(int $userId): User エラー処理の漏れを防ぐ工夫 具体的な例外型を指定してcatchする キャッチした例外を「握り潰さない」 31/34
  16. 静的解析ツールの活用 PHPStanの例外チェック機能 throwしてるのに @throws アノテーションがない場 合や、catchしていない例外がある場合に警告 ビジネス例外を明示的に定義 exceptions: checkedExceptionClasses: #

    独自定義した例外の基底クラス - Package\Domain\Exception\DomainException check: missingCheckedExceptionInThrows: true tooWideThrowType: true PHPStormのInspections PHP -> Analysis -> Unchecked Exceptions スルーしても良い例外を設定することで、過度なcatch を防ぐ 32/34