From 56eb783f4e5f28fedfc15464690ed0f2e9299496 Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Thu, 18 Jun 2026 23:37:02 +0900 Subject: [PATCH 1/4] test: verify error-side exhaustiveness for enum and sealed error types --- tests/Types/result.php | 81 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/Types/result.php b/tests/Types/result.php index 984942b..6831f9b 100644 --- a/tests/Types/result.php +++ b/tests/Types/result.php @@ -348,3 +348,84 @@ 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()) { + assertType('Valbeat\Result\Tests\Types\HttpError', $result->unwrapErr()); + + return match ($result->unwrapErr()) { + 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'; +} + +/** + * 既知の落とし穴のピン留め: instanceof Err では E が失われ、enum/sealed であっても + * unwrapErr() は mixed になる。値を扱う分岐は isErr() を使う(上の2ケース参照). + * + * @param Result $result + */ +function testInstanceofErrLosesErrorType(Result $result): void +{ + if ($result instanceof Err) { + assertType('mixed', $result->unwrapErr()); + } +} From 6fb81c7ce3989b17775c9a1fc84b9446f02c0c4c Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Thu, 18 Jun 2026 23:41:28 +0900 Subject: [PATCH 2/4] docs: document exhaustive error matching for enum and sealed error types --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index f7b5a2f..f351f21 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,14 @@ 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). The error classes themselves are not generic, + so their `instanceof` checks don't suffer the type-argument loss. - **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. From 7be51588dd0b76063dac1bbad9453223c976103f Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Thu, 18 Jun 2026 23:44:12 +0900 Subject: [PATCH 3/4] test: pin instanceof Err type loss for sealed error type too --- tests/Types/result.php | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/Types/result.php b/tests/Types/result.php index 6831f9b..b3594bc 100644 --- a/tests/Types/result.php +++ b/tests/Types/result.php @@ -418,12 +418,25 @@ function testSealedErrorExhaustiveness(Result $result): string } /** - * 既知の落とし穴のピン留め: instanceof Err では E が失われ、enum/sealed であっても - * unwrapErr() は mixed になる。値を扱う分岐は isErr() を使う(上の2ケース参照). + * 既知の落とし穴のピン留め(enum エラー): instanceof Err では E が失われ、 + * enum であっても unwrapErr() は mixed になる。値を扱う分岐は isErr() を使う(上の2ケース参照). * * @param Result $result */ -function testInstanceofErrLosesErrorType(Result $result): void +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()); From aa02daa048a07e7201c997d41174a11f515c6505 Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Fri, 19 Jun 2026 00:12:17 +0900 Subject: [PATCH 4/4] docs+test: address Copilot review (conditional wording, dedupe unwrapErr call) --- README.md | 5 +++-- tests/Types/result.php | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f351f21..f28649f 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,9 @@ and leans on several of its generics features: 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). The error classes themselves are not generic, - so their `instanceof` checks don't suffer the type-argument loss. + `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 b3594bc..1ba6366 100644 --- a/tests/Types/result.php +++ b/tests/Types/result.php @@ -385,9 +385,10 @@ final class NetworkFailure implements AppError function testEnumErrorExhaustiveness(Result $result): string { if ($result->isErr()) { - assertType('Valbeat\Result\Tests\Types\HttpError', $result->unwrapErr()); + $error = $result->unwrapErr(); + assertType('Valbeat\Result\Tests\Types\HttpError', $error); - return match ($result->unwrapErr()) { + return match ($error) { HttpError::NotFound => 'not found', HttpError::Forbidden => 'forbidden', };