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

Result型で“失敗”を型にするPHPコードの書き方

 Result型で“失敗”を型にするPHPコードの書き方

PHPではtry-catchによる例外処理が一般的ですが、「どこで例外を処理すべきか?」「本当にこの場面で例外を使うべきなのか?」と迷ったことはありませんか?
過剰なエラーハンドリングや、catchしたけれど何もしていない“握りつぶし”が積み重なると、責任の所在が曖昧になり、コードの見通しや保守性にも悪影響を及ぼします。

こうした課題へのヒントとして、Haskell、Elm、Rust、Kotlin、Swiftなどで言語で採用されているResult型の考え方を、PHPに応用するアプローチがあります。
Result型は、失敗を型として明示的に扱い、成功も失敗も返り値で表現する設計手法です。
これにより、「どこで何が失敗しうるか」「どこまでが関数の責務か」がコードから読み取れるようになり、処理の流れや責任が明快になります。

本トークでは、Result型によるエラーの設計方法や、例外との使い分けについて、以下の観点から実装例を交えて解説します:

- エラーの分類と責務の整理
- 例外との使い分け
- PHPでResult型を実装する方法

Result型を導入するかどうかに関わらず、エラーをどう設計するかを見直すヒントとして、この考え方を持ち帰っていただけると嬉しいです!

Result型の実装例
https://github.com/valbeat/php-result

https://fortee.jp/phpcon-2025/proposal/196d87d1-5cb4-437a-b063-d523096d4ae4

Avatar for Takuma Kajikawa

Takuma Kajikawa

June 28, 2025
Tweet

More Decks by Takuma Kajikawa

Other Decks in Programming

Transcript

  1. try-catchの課題 // 何が起きるか実行するまで分からない try { $user = $userRepository->find($userId); // NotFoundException?

    $payment = $paymentService->charge($user, $amount); // PaymentException? ValidationException? $notificationService->notify($user, $payment); // NetworkException? } catch (Exception $e) { // どの処理で、どんなエラーが起きた? // ビジネスエラー?技術的エラー? } どの例外が発生するか分からない @throws に強制力がない エラー処理の場所が曖昧 7/49
  2. Result 型 try-catchとは別のエラーハンドリングのための型 戻り値として「 成功 」または「 失敗 」を持つ 成功 (Ok):

    正常な値 を返す 失敗 (Err): エラー情報 を返す 成功した場合、失敗した場合など処理を分岐できる Haskell、Elm、Rust、Kotlin、Swiftなどで採用 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(); } 11/49
  3. エラーの返し方の比較 try-catch throw /** * @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型 return /** * @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); } 12/49
  4. エラーハンドリングの比較 try-catch catch try { $order = $this->orderService->placeOrder($input); } catch

    (UserNotFoundException $e) { // ユーザーが見つからない場合の処理 } catch (OutOfStockException $e) { // 在庫切れの処理 } catch (GuestUserCannotOrderException $e) { // ゲストユーザーの注文処理 } catch (RestrictedProductIncludedException $e) { // 制限商品が含まれている場合の処理 } catchしない書き方も出来てしまう 直接呼び出していない場所でもcatchできる 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(); 13/49
  5. とりあえず... bool値と結果を持つ実装 成功したかどうかをbool値で表現し、結果とエラーを持つ mixedで型安全性が不足 → ジェネリクスで解決 class Result { private

    function __construct( private bool $isSuccess, private mixed $value, private mixed $error ) {} public static function ok(mixed $value): self { return new self(true, $value, null); } public static function err(mixed $error): self { return new self(false, null, $error); } public function isOk(): bool { return $this->isSuccess; } public function isErr(): bool { return !$this->isSuccess; } } 16/49
  6. Ok(T) Err(E) Result 値: T エラー: E ジェネリクスを使ったResultの実装 基底クラスの定義 /**

    * @template T 成功時の値の型 * @template E 失敗時のエラーの型 */ abstract readonly class Result{} 18/49
  7. 成功と失敗それぞれの具象クラスを定義 成功: @template<T> と @extends Result<T, never> 失敗: @template<E> と

    @extends Result<never, E> /** * @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 ) { } } 19/49
  8. Result型のメソッド実装 参考: RustのResult型 https://doc.rust-lang.org/std/result/ 基本操作 isOk() 成功かどうかを確認 isErr() 失敗かどうかを確認 unwrap()

    成功時の値を取得 unwrapErr() 失敗時のエラーを取得 関数型プログラミング map(fn) 成功値に関数を適用 mapErr(fn) エラー値に関数を適用 andThen(fn) 成功時に別のResultを返す関数を適用 orElse(fn) 失敗時に別のResultを返す関数を適用 20/49
  9. 基本的なメソッドの実装 判定メソッドと値取得メソッドを実装 Ok public function isOk(): bool { return true;

    } public function isErr(): bool { return false; } /** * @return T */ public function unwrap(): mixed { return $this->value; } public function unwrapErr(): never { throw new LogicException('called Result::unwrapErr() on an Ok value'); } Err public function isOk(): bool { return false; } public function isErr(): bool { return true; } public function unwrap(): never { throw new LogicException('called Result::unwrap() on an Err value'); } /** * @return E */ public function unwrapErr(string $message = ''): mixed { return $this->value; } 21/49
  10. 基本的な使い方 isErr で失敗をチェックし、 unwrapErr でエラー値を取得 エラーに応じた処理を行う // 失敗した場合の処理 if ($result->isErr())

    { $error = $result->unwrapErr(); return match ($error) { OrderError::UserNotFound => // ユーザーが見つからない場合の処理 OrderError::OutOfStock => // 在庫切れの処理 OrderError::GuestUserCannotOrder => // ゲストユーザーの注文処理 OrderError::RestrictedProductIncluded => // 制限商品が含まれている場合の処理 }; } // 成功した場合のみ値を取り出せる $order = $result->unwrap(); 22/49
  11. 必要に応じてメソッドを追加 Ok // 成功値を変換 public function map(callable $fn): Result {

    return new Ok($fn($this->value)); } // 成功時に別のResultを返す関数を適用 public function andThen(callable $fn): Result { return $fn($this->value); } // エラー値の変換(Okなので何もしない) public function mapErr(callable $fn): Result { return $this; } // 失敗時の代替(Okなので自身を返す) public function orElse(callable $fn): Result { return $this; } Err // 成功値の変換(Errなので何もしない) public function map(callable $fn): Result { return $this; } // 成功時の関数適用(Errなので何もしない) public function andThen(callable $fn): Result { return $this; } // エラー値を変換 public function mapErr(callable $fn): Result { return new Err($fn($this->value)); } // 失敗時に別のResultを返す public function orElse(callable $fn): Result { return $fn($this->value); } 23/49
  12. 応用的な使い方 関数型メソッドによる連鎖的な処理 map で成功値を変換 andThen で次の処理を適用 // 注文IDから注文詳細情報を取得する例 $orderDetails =

    $this->orderRepository ->findById($orderId) // Result<Order, NotFoundError> ->map(fn($order) => $order->toArray()) // Result<array, NotFoundError> ->map(fn($data) => $this->enrichWithUserInfo($data)) // Result<array, NotFoundError> ->mapErr(fn($err) => new OrderNotFoundError($orderId)); // エラーを変換 // 複数のResult型を連鎖 $result = $this->validateInput($request) // Result<ValidInput, ValidationError> ->andThen(fn($input) => $this->createOrder($input)) // Result<Order, OrderError> ->andThen(fn($order) => $this->processPayment($order)) // Result<Payment, PaymentError> ->map(fn($payment) => new OrderCompleted($payment)); // 成功時の値を変換 24/49
  13. まとめると... PHPでのResult型実装 ジェネリクスで型安全に実装可能 静的解析ツール PHPStan との相性が良い map、andThenなどの関数型メソッドで処理を連鎖 実装のポイント isOk()、isErr()で状態をチェック unwrap()、unwrapErr()で値を取得

    関数合成で成功パスと失敗パスを分離 RustのResult型を参考にしたが、メソッド名などはチームのスタイルに合わせて調整 (Success、Failure、Eitherなどの表現も一般的) 26/49
  14. 現実のコードベースでよく見る光景 パターン1:全部catch try { // 複雑なビジネスロジック $result = $this->doEverything(); }

    catch (Exception $e) { Log::error($e); } パターン2:catch地獄 try { $order = $this->createOrder(); } catch (UserNotFoundException $e) { // 処理A } catch (InsufficientFundsException $e) { // 処理B } catch (OutOfStockException $e) { // 処理C } catch (DatabaseException $e) { // 処理D } catch (Exception $e) { // その他全部... } 結果:ビジネスエラーと技術的エラーが混在してカオスに 32/49
  15. Entityの作成をResult型で表現 失敗の種類をEnumで定義 enum OrderError { case ValidationError; case InsufficientStock; case

    PaymentFailed; case ShippingNotAvailable; } ワークフローでResult型を活用 /** * @return Result<Order, OrderError> */ public function crate(OrderRequest $request): Result { } 44/49
  16. Entity作成に必要な処理の定義 Result型を返す小さな関数に分割 function validateOrderData(array $data): Result { if (empty($data['items'])) {

    return Result::err(OrderError::ValidationError); } return Result::ok(new Order($data)); } function checkInventory(Order $order): Result { foreach ($order->items as $item) { if (!hasStock($item)) { return Result::err(OrderError::InsufficientStock); } } return Result::ok($order); } function processPayment(Order $order): Result { if (!$paymentGateway->charge($order->total)) { return Result::err(OrderError::PaymentFailed); } return Result::ok($order); } 45/49
  17. 小さな関数を組み合わせてビジネスロジックを作成 andThen で成功した時だけ次の処理を実行する class OrderFactory { /** * @return Result<Order,

    OrderError> */ public function create(PlaceOrderRequest $request): Result { return Result::ok($request->toArray()) ->andThen(fn($data) => $this->validateOrderData($data)) ->andThen(fn($order) => $this->checkInventory($order)) ->andThen(fn($order) => $this->processPayment($order)) ->andThen(fn($order) => $this->scheduleShipping($order)); } } 46/49
  18. ユースケース側でResult型から例外への変換 unwrapErr() で失敗時のエラーを取得し、例外を投げる 例外を投げることで、フレームワークのエラーハンドラに任せる また、検証が終わった後の処理の技術的例外で表現する public function exec(OrderCreateRequest $request): OrderDto

    { $result = $this->orderFactory->create($request); if ($result->isErr()) { throw match ($result->unwrapErr()) { OrderError::ValidationError => new BadRequestException(), OrderError::InsufficientStock => new ConflictException(), OrderError::PaymentFailed => new PaymentRequiredException(), OrderError::ShippingNotAvailable => new ServiceUnavailableException(), }; } $order = $result->unwrap(); // 永続化のエラーは例外で表現することで、従来通りロールバックする DB::transaction(fn() => $this->orderRepository->save($order)); return OrderDto::from($order); } 47/49
  19. 例外とResult型の使い分け エラーの分類 ビジネスエラー: 業務上想定される失敗 → Result型 技術的エラー: システムの異常状態 → 例外

    使い分けの指針 ドメイン層:ビジネスエラーをResult型で表現 インフラ層:技術的例外をcatchしてResult型に変換 アプリケーション層:Result型を適切なレスポンスに変換 実装例から学んだこと ビジネスルールの検証はResult型で明示的に インフラエラーは例外として上位に委譲 コントローラーでビジネスエラーを適切にハンドリング 48/49