diff --git a/README.md b/README.md index f7b5a2f..f28649f 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,15 @@ and leans on several of its generics features: via `@phpstan-assert-if-true`; `unwrap()`/`unwrapErr()` use conditional return types (`never` on the impossible side), and `unwrapOr()`/`unwrapOrElse()` resolve to `T` on `Ok` and to the default's type on `Err`. +- **Exhaustive error matching** — when the error type `E` is a native `enum` or a + `@phpstan-sealed` union, the error value can be matched exhaustively (over the + enum cases, or `instanceof` arms for a sealed union) and PHPStan enforces it — a + missing case becomes an analysis error. Reach the error through `isErr()` + + `unwrapErr()`, or the `match()` method's `err` arm; narrowing with + `instanceof Err` drops `E` to `mixed` and loses the enum/sealed type (the same + `instanceof` limitation as above). As long as the error classes are themselves + non-generic, their own `instanceof` checks have no type arguments to lose + (unlike the generic `Ok`/`Err`). - **Precise concrete receivers** — when the receiver is statically `Ok` or `Err`, no-op methods keep their exact type (`$ok->orElse(...)` stays `Ok`, `$err->andThen(...)` stays `Err`) instead of widening to a union. diff --git a/tests/Types/result.php b/tests/Types/result.php index 984942b..1ba6366 100644 --- a/tests/Types/result.php +++ b/tests/Types/result.php @@ -348,3 +348,98 @@ function testMap(Result $result): void $result->mapErr(static fn (RuntimeException $e): LogicException => new LogicException($e->getMessage())), ); } + +/** + * エラー側の網羅性テスト用フィクスチャ: native enum. + */ +enum HttpError +{ + case NotFound; + case Forbidden; +} + +/** + * エラー側の網羅性テスト用フィクスチャ: @phpstan-sealed なエラー union. + * + * @phpstan-sealed ValidationFailure|NetworkFailure + */ +interface AppError +{ +} + +final class ValidationFailure implements AppError +{ +} + +final class NetworkFailure implements AppError +{ +} + +/** + * エラー型が native enum の場合: isErr() 経由なら unwrapErr() が enum 型を保ち、 + * 全ケースを網羅する match は default なしで網羅と認識される. + * (いずれかのケースを落とすと phpstan analyse がエラーになるため、これ自体が網羅性のピン留めになる) + * + * @param Result $result + */ +function testEnumErrorExhaustiveness(Result $result): string +{ + if ($result->isErr()) { + $error = $result->unwrapErr(); + assertType('Valbeat\Result\Tests\Types\HttpError', $error); + + return match ($error) { + HttpError::NotFound => 'not found', + HttpError::Forbidden => 'forbidden', + }; + } + + return 'ok'; +} + +/** + * エラー型が @phpstan-sealed union の場合: isErr() 経由で取り出した値に対する + * match(true)+instanceof が網羅と認識される(sealed 指定が前提。エラークラスは非ジェネリックなので型引数喪失は起きない). + * + * @param Result $result + */ +function testSealedErrorExhaustiveness(Result $result): string +{ + if ($result->isErr()) { + $error = $result->unwrapErr(); + assertType('Valbeat\Result\Tests\Types\AppError', $error); + + return match (true) { + $error instanceof ValidationFailure => 'validation', + $error instanceof NetworkFailure => 'network', + }; + } + + return 'ok'; +} + +/** + * 既知の落とし穴のピン留め(enum エラー): instanceof Err では E が失われ、 + * enum であっても unwrapErr() は mixed になる。値を扱う分岐は isErr() を使う(上の2ケース参照). + * + * @param Result $result + */ +function testInstanceofErrLosesEnumErrorType(Result $result): void +{ + if ($result instanceof Err) { + assertType('mixed', $result->unwrapErr()); + } +} + +/** + * 既知の落とし穴のピン留め(sealed エラー): sealed union でも instanceof Err では + * E が失われ unwrapErr() は mixed になる. + * + * @param Result $result + */ +function testInstanceofErrLosesSealedErrorType(Result $result): void +{ + if ($result instanceof Err) { + assertType('mixed', $result->unwrapErr()); + } +}