From b99e722094e87eb150eb43e7d4906362d9c633c1 Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Sat, 6 Jun 2026 08:39:12 +0900 Subject: [PATCH 1/6] test: add PHPStan type-level assertions for covariance, sealed narrowing, and error-type composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently failing (RED) — documents the desired type-level behavior: - Ok assignable to Result (covariance) - andThen/and compose error types as E|F - orElse/or compose success types as T|U - instanceof Ok else-branch narrows to Err (sealed) - mapOr returns U (not T|U) Inspired by: - https://zenn.dev/higaki/articles/my-php-result-type - https://zenn.dev/higaki/articles/my-php-result-type-more --- tests/types/result.php | 142 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 tests/types/result.php diff --git a/tests/types/result.php b/tests/types/result.php new file mode 100644 index 0000000..e170c54 --- /dev/null +++ b/tests/types/result.php @@ -0,0 +1,142 @@ + (= Result) を + * Result として返せる. + * + * @return Result + */ +function parsePositiveInt(string $input): Result +{ + $value = (int) $input; + if ($value > 0) { + return new Ok($value); + } + + return new Err(new RuntimeException('not a positive int')); +} + +/** + * @return Result + */ +function findNameById(int $id): Result +{ + if ($id === 42) { + return new Ok('Alice'); + } + + return new Err(new LogicException('not found')); +} + +/** + * @param Result $result + */ +function testAndThenComposesErrorTypes(Result $result): void +{ + // andThen は異なるエラー型を返すコールバックを受け取れ、エラー型は E|F に合成される + $chained = $result->andThen(findNameById(...)); + assertType('Valbeat\Result\Result', $chained); +} + +/** + * @param Result $result + * @param Result $other + */ +function testAndComposesErrorTypes(Result $result, Result $other): void +{ + assertType('Valbeat\Result\Result', $result->and($other)); +} + +/** + * @param Result $result + */ +function testOrElseComposesSuccessTypes(Result $result): void +{ + // orElse は異なる成功型を返すコールバックを受け取れ、成功型は T|U に合成される + $recovered = $result->orElse(static fn (RuntimeException $e): Result => findNameById(0)); + assertType('Valbeat\Result\Result', $recovered); +} + +/** + * @param Result $result + * @param Result $other + */ +function testOrComposesSuccessTypes(Result $result, Result $other): void +{ + assertType('Valbeat\Result\Result', $result->or($other)); +} + +/** + * @param Result $result + */ +function testIsOkNarrowing(Result $result): void +{ + if ($result->isOk()) { + assertType('Valbeat\Result\Ok', $result); + assertType('int', $result->unwrap()); + } else { + assertType('Valbeat\Result\Err', $result); + assertType('RuntimeException', $result->unwrapErr()); + } +} + +/** + * sealed のテスト: instanceof Ok の else 分岐で Err に絞り込まれる. + * + * @param Result $result + */ +function testInstanceofNarrowing(Result $result): bool +{ + if ($result instanceof Ok) { + assertType('Valbeat\Result\Ok', $result); + + return true; + } + assertType('Valbeat\Result\Err', $result); + + return false; +} + +function stringify(int $value): string +{ + return 'value: ' . $value; +} + +/** + * @param Result $result + */ +function testUnwrapVariants(Result $result, string $default, float $fallback): void +{ + assertType('int|string', $result->unwrapOr($default)); + assertType('float|int', $result->unwrapOrElse(static fn (RuntimeException $e): float => $fallback)); + assertType('string', $result->mapOr($default, stringify(...))); +} + +/** + * @param Result $result + */ +function testMap(Result $result): void +{ + assertType( + 'Valbeat\Result\Result', + $result->map(stringify(...)), + ); + assertType( + 'Valbeat\Result\Result', + $result->mapErr(static fn (RuntimeException $e): LogicException => new LogicException($e->getMessage())), + ); +} From 6c932d01f88c474c1176a42095e199a389ae6b4c Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Sat, 6 Jun 2026 08:47:32 +0900 Subject: [PATCH 2/6] feat: add sealed interface, covariant templates, and error-type composition - Mark Result as @phpstan-sealed Ok|Err so PHPStan treats Ok/Err as exhaustive (match(true) over instanceof checks needs no default arm) - Make T/E covariant (@template-covariant) so Ok (= Result) and Err (= Result) are assignable to any Result - andThen/and now accept callbacks/results with a different error type F and compose error types as Result - orElse/or now accept a different success type U and compose success types as Result - Fix mapOr return type (U, not T|U) and tighten unwrapOrElse to a conditional return type - Conditional return types now test against Ok/Err to keep covariant templates out of invariant positions - Widen literal values via helpers in tests: covariant templates preserve constant types (new Ok(10) is Ok<10>), which made literal comparisons always-true/false at PHPStan level max Inspired by: - https://zenn.dev/higaki/articles/my-php-result-type - https://zenn.dev/higaki/articles/my-php-result-type-more --- src/Err.php | 2 +- src/Ok.php | 5 +++-- src/Result.php | 40 +++++++++++++++++++++++++--------------- tests/ErrTest.php | 22 +++++++++++++++++++--- tests/OkTest.php | 12 ++++++++++-- tests/types/result.php | 18 +++++++++++++++--- 6 files changed, 73 insertions(+), 26 deletions(-) diff --git a/src/Err.php b/src/Err.php index e143ceb..4a6d214 100644 --- a/src/Err.php +++ b/src/Err.php @@ -9,7 +9,7 @@ /** * Err はエラー値を表します. * - * @template E + * @template-covariant E * * @implements Result */ diff --git a/src/Ok.php b/src/Ok.php index a5f93b2..89a894d 100644 --- a/src/Ok.php +++ b/src/Ok.php @@ -9,7 +9,7 @@ /** * Ok は成功値を表します. * - * @template T + * @template-covariant T * * @implements Result */ @@ -63,7 +63,8 @@ public function unwrapErr(): never } /** - * @param T $default + * @template U + * @param U $default * @return T */ #[Override] diff --git a/src/Result.php b/src/Result.php index 9019b29..e358e3e 100644 --- a/src/Result.php +++ b/src/Result.php @@ -7,8 +7,10 @@ /** * Result型は、成功(Ok)または失敗(Err)を表現します。 * - * @template T 成功時の値の型 - * @template E 失敗時のエラーの型 + * @template-covariant T 成功時の値の型 + * @template-covariant E 失敗時のエラーの型 + * + * @phpstan-sealed Ok|Err */ interface Result { @@ -53,14 +55,14 @@ public function isErrAnd(callable $fn): bool; /** * 成功値を返します。失敗の場合は例外を投げます. * - * @return ($this is Ok ? T : never) + * @return ($this is Ok ? T : never) */ public function unwrap(): mixed; /** * エラー値を返します。成功の場合は例外を投げます. * - * @return ($this is Err ? E : never) + * @return ($this is Err ? E : never) */ public function unwrapErr(): mixed; @@ -69,7 +71,7 @@ public function unwrapErr(): mixed; * * @template U * @param U $default - * @return ($this is Ok ? T : U) + * @return ($this is Ok ? T : U) */ public function unwrapOr(mixed $default): mixed; @@ -79,7 +81,7 @@ public function unwrapOr(mixed $default): mixed; * @template U * @param callable(E): U $fn * - * @return T|U + * @return ($this is Ok ? T : U) */ public function unwrapOrElse(callable $fn): mixed; @@ -131,7 +133,7 @@ public function inspectErr(callable $fn): self; * @param U $default * @param callable(T): U $fn * - * @return T|U + * @return U */ public function mapOr(mixed $default, callable $fn): mixed; @@ -151,43 +153,51 @@ public function mapOrElse(callable $default_fn, callable $fn): mixed; * 成功の場合は第2の結果を返し、失敗の場合は最初のエラーを返します. * * @template U + * @template F * - * @param Result $res + * @param Result $res * - * @return Result + * @return Result */ public function and(self $res): self; /** * 成功の場合は関数を適用し、失敗の場合は現在のエラーを返します. * + * 関数は元と異なるエラー型を返せます。エラー型は E|F に合成されます. + * * @template U + * @template F * - * @param callable(T): Result $fn + * @param callable(T): Result $fn * - * @return Result + * @return Result */ public function andThen(callable $fn): self; /** * 失敗の場合は第2の結果を返し、成功の場合は最初の値を返します. * + * @template U * @template F * - * @param Result $res + * @param Result $res * - * @return Result + * @return Result */ public function or(self $res): self; /** * 失敗の場合は関数を適用し、成功の場合は現在の値を返します. * + * 関数は元と異なる成功型を返せます。成功型は T|U に合成されます. + * + * @template U * @template F * - * @param callable(E): Result $fn + * @param callable(E): Result $fn * - * @return Result + * @return Result */ public function orElse(callable $fn): self; diff --git a/tests/ErrTest.php b/tests/ErrTest.php index afe86fa..6cf3f07 100644 --- a/tests/ErrTest.php +++ b/tests/ErrTest.php @@ -36,7 +36,7 @@ public function isOkAnd_always_returns_false(): void #[Test] public function isErrAnd_whenCallbackReturnsTrue_returns_true(): void { - $err = new Err('critical'); + $err = new Err(self::asString('critical')); $result = $err->isErrAnd(fn ($error) => $error === 'critical'); $this->assertTrue($result); } @@ -44,7 +44,7 @@ public function isErrAnd_whenCallbackReturnsTrue_returns_true(): void #[Test] public function isErrAnd_whenCallbackReturnsFalse_returns_false(): void { - $err = new Err('warning'); + $err = new Err(self::asString('warning')); $result = $err->isErrAnd(fn ($error) => $error === 'critical'); $this->assertFalse($result); } @@ -114,7 +114,7 @@ public function unwrapOrElse_calls_function(): void #[Test] public function unwrapOrElse_receives_error_value(): void { - $err = new Err(404); + $err = new Err(self::asInt(404)); $result = $err->unwrapOrElse(fn ($code) => $code === 404 ? 'Not Found' : 'Unknown'); $this->assertSame('Not Found', $result); } @@ -314,4 +314,20 @@ public function exceptionAsErrorValue_handles_exception(): void $handled = $err->mapErr(fn ($e) => $e->getMessage()); $this->assertSame('Something went wrong', $handled->unwrapErr()); } + + /** + * リテラル型を int に広げます(共変テンプレートは定数型を保持するため). + */ + private static function asInt(int $value): int + { + return $value; + } + + /** + * リテラル型を string に広げます(共変テンプレートは定数型を保持するため). + */ + private static function asString(string $value): string + { + return $value; + } } diff --git a/tests/OkTest.php b/tests/OkTest.php index 787bcaa..3f526a9 100644 --- a/tests/OkTest.php +++ b/tests/OkTest.php @@ -28,7 +28,7 @@ public function isErr_returns_false(): void #[Test] public function isOkAnd_whenCallbackReturnsTrue_returns_true(): void { - $ok = new Ok(10); + $ok = new Ok(self::asInt(10)); $result = $ok->isOkAnd(fn ($value) => $value > 5); $this->assertTrue($result); } @@ -36,7 +36,7 @@ public function isOkAnd_whenCallbackReturnsTrue_returns_true(): void #[Test] public function isOkAnd_whenCallbackReturnsFalse_returns_false(): void { - $ok = new Ok(3); + $ok = new Ok(self::asInt(3)); $result = $ok->isOkAnd(fn ($value) => $value > 5); $this->assertFalse($result); } @@ -286,4 +286,12 @@ public function chainingOperations_applies_transformations(): void $this->assertInstanceOf(Ok::class, $result); $this->assertSame(22, $result->unwrap()); // (10 * 2) + 5 - 3 = 22 } + + /** + * リテラル型を int に広げます(共変テンプレートは定数型を保持するため). + */ + private static function asInt(int $value): int + { + return $value; + } } diff --git a/tests/types/result.php b/tests/types/result.php index e170c54..1fc4964 100644 --- a/tests/types/result.php +++ b/tests/types/result.php @@ -102,15 +102,27 @@ function testIsOkNarrowing(Result $result): void function testInstanceofNarrowing(Result $result): bool { if ($result instanceof Ok) { - assertType('Valbeat\Result\Ok', $result); - return true; } - assertType('Valbeat\Result\Err', $result); + // sealed の効果: instanceof Ok の else 分岐で Err に絞り込まれる + assertType('Valbeat\Result\Err', $result); return false; } +/** + * sealed の効果: Ok と Err で全ケース網羅と判断され、match が非網羅エラーにならない. + * + * @param Result $result + */ +function testExhaustiveMatch(Result $result): string +{ + return match (true) { + $result instanceof Ok => 'ok', + $result instanceof Err => 'err', + }; +} + function stringify(int $value): string { return 'value: ' . $value; From 0cc5f392120b31b28a3cd5a11a3a75c53516e1cb Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Sat, 6 Jun 2026 08:49:47 +0900 Subject: [PATCH 3/6] test: cover andThen/orElse chains that mix success and error types Exercise the widened signatures at runtime: an Ok chained into a callback that can fail with a different error type, an Err passing through andThen unchanged, and an Err recovered into a different success type via orElse. --- tests/ErrTest.php | 20 ++++++++++++++++++++ tests/OkTest.php | 26 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/tests/ErrTest.php b/tests/ErrTest.php index 6cf3f07..30b5aa9 100644 --- a/tests/ErrTest.php +++ b/tests/ErrTest.php @@ -215,6 +215,16 @@ public function andThen_returns_self(): void $this->assertSame('error', $result->unwrapErr()); } + #[Test] + public function andThen_withDifferentErrorType_keeps_original_error(): void + { + $err = new Err(self::asString('validation failed')); + $result = $err->andThen(fn ($x) => new Err(new \DomainException('unreachable'))); + + $this->assertSame($err, $result); + $this->assertSame('validation failed', $result->unwrapErr()); + } + #[Test] public function or_withAnotherErr_returns_second_err(): void { @@ -234,6 +244,16 @@ public function orElse_canReturnAnotherErr_returns_new_err(): void $this->assertSame('HTTP Error: 404', $result->unwrapErr()); } + #[Test] + public function orElse_withDifferentSuccessType_recovers(): void + { + $err = new Err(self::asString('original')); + $result = $err->orElse(fn ($e) => new Ok(\strlen($e))); + + $this->assertInstanceOf(Ok::class, $result); + $this->assertSame(8, $result->unwrap()); + } + #[Test] public function match_calls_err_function(): void { diff --git a/tests/OkTest.php b/tests/OkTest.php index 3f526a9..309282c 100644 --- a/tests/OkTest.php +++ b/tests/OkTest.php @@ -190,6 +190,32 @@ public function andThen_applies_function(): void $this->assertSame(20, $result->unwrap()); } + #[Test] + public function andThen_withDifferentErrorType_chains_results(): void + { + $ok = new Ok(self::asInt(10)); + $result = $ok->andThen( + fn ($x) => $x > 5 ? new Ok("value: $x") : new Err(new \DomainException('too small')), + ); + + $this->assertInstanceOf(Ok::class, $result); + $this->assertSame('value: 10', $result->unwrap()); + } + + #[Test] + public function andThen_withDifferentErrorType_returns_new_err(): void + { + $ok = new Ok(self::asInt(3)); + $result = $ok->andThen( + fn ($x) => $x > 5 ? new Ok("value: $x") : new Err(new \DomainException('too small')), + ); + + $this->assertInstanceOf(Err::class, $result); + $error = $result->unwrapErr(); + $this->assertInstanceOf(\DomainException::class, $error); + $this->assertSame('too small', $error->getMessage()); + } + #[Test] public function or_returns_self(): void { From d383634d05f7f84f3a235fcea16aedea77305bb8 Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Sat, 6 Jun 2026 08:50:30 +0900 Subject: [PATCH 4/6] docs: document type safety guarantees and error-type composition --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/README.md b/README.md index f73a3fb..d40ccb6 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,21 @@ $result = (new Ok(2)) echo $result->unwrapErr(); // "Too small" ``` +Each step in a chain may fail with a **different error type**. The error types +are composed into a union, so PHPStan tracks every error the chain can produce: + +```php +/** @return Result */ +function validateUserId(string $raw): Result { /* ... */ } + +/** @return Result */ +function findUserById(UserId $id): Result { /* ... */ } + +// PHPStan infers Result +$user = validateUserId($request['id']) + ->andThen(findUserById(...)); +``` + ### Combining Results ```php @@ -114,6 +129,30 @@ $result = (new Err("oops")) ->inspectErr(fn($e) => error_log("Error occurred: $e")); ``` +## Type Safety + +This library is designed to be used with [PHPStan](https://phpstan.org/) at level max +and leans on several of its generics features: + +- **Sealed interface** — `Result` is annotated with `@phpstan-sealed Ok|Err`, so + PHPStan knows `Ok` and `Err` are the only implementations. A `match (true)` over + `instanceof` checks is recognized as exhaustive, and the `else` branch of an + `instanceof Ok` check narrows to `Err`. +- **Covariant type parameters** — `T` and `E` are declared `@template-covariant`, + so `Ok` (which is `Result`) and `Err` (which is `Result`) + are assignable to any `Result`. A function declared to return + `Result` can simply `return new Ok($user);`. +- **Error-type composition** — `andThen()`/`and()` widen the error channel to + `E|F` and `orElse()`/`or()` widen the success channel to `T|U`, so chains that + mix failure types stay precisely typed. +- **Type narrowing** — `isOk()`/`isErr()` narrow `$result` to `Ok`/`Err` + via `@phpstan-assert-if-true`, and `unwrap()`/`unwrapErr()`/`unwrapOr()` use + conditional return types (`never` on the impossible side). + +Note: because the templates are covariant, PHPStan preserves constant value types +(`new Ok(10)` is `Ok<10>`, not `Ok`). Type a variable or parameter as `int` +if you want the widened type. + ## API Reference ### Result Methods From 723c77e2e0101f3842f118f7d178eb8d2216d559 Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Sat, 6 Jun 2026 09:37:26 +0900 Subject: [PATCH 5/6] fix: keep concrete Ok/Err receivers precise on no-op methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review finding: the widened interface signatures leaked impossible types when the receiver is statically Ok or Err — e.g. $ok->orElse(fn => Ok) inferred Result even though Ok::orElse always returns $this, and Ok::mapOr unified the default's type into the result even though Ok never returns the default. Add per-class PHPDoc overrides on the no-op/passthrough sides: - Ok: map => Ok; mapErr/inspect/inspectErr/or/orElse => $this; mapOr/mapOrElse split the default's template from the callback's - Err: mapErr => Err; map/inspect/inspectErr/and/andThen => $this; mapOr/mapOrElse split likewise Covered by new assertType cases for concrete receivers in tests/types/result.php. Also fix the README Type Safety section: unwrapOr/unwrapOrElse resolve to the default's type on Err (not never), and note that the and/or channel widening deliberately diverges from Rust. --- README.md | 11 ++++++++--- src/Err.php | 40 ++++++++++++++++++++++++++++++++++++++++ src/Ok.php | 28 ++++++++++++++++++++++++++-- tests/types/result.php | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d40ccb6..b8bf982 100644 --- a/README.md +++ b/README.md @@ -144,10 +144,15 @@ and leans on several of its generics features: `Result` can simply `return new Ok($user);`. - **Error-type composition** — `andThen()`/`and()` widen the error channel to `E|F` and `orElse()`/`or()` widen the success channel to `T|U`, so chains that - mix failure types stay precisely typed. + mix failure types stay precisely typed. (This deliberately diverges from Rust, + whose `and`/`or` family keeps the other channel's type fixed.) - **Type narrowing** — `isOk()`/`isErr()` narrow `$result` to `Ok`/`Err` - via `@phpstan-assert-if-true`, and `unwrap()`/`unwrapErr()`/`unwrapOr()` use - conditional return types (`never` on the impossible side). + 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`. +- **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. Note: because the templates are covariant, PHPStan preserves constant value types (`new Ok(10)` is `Ok<10>`, not `Ok`). Type a variable or parameter as `int` diff --git a/src/Err.php b/src/Err.php index 4a6d214..968d589 100644 --- a/src/Err.php +++ b/src/Err.php @@ -86,24 +86,40 @@ public function unwrapOrElse(callable $fn): mixed return $fn($this->value); } + /** + * @return $this + */ #[Override] public function map(callable $fn): Result { return $this; } + /** + * @template F + * + * @param callable(E): F $fn + * + * @return Err + */ #[Override] public function mapErr(callable $fn): Result { return new self($fn($this->value)); } + /** + * @return $this + */ #[Override] public function inspect(callable $fn): Result { return $this; } + /** + * @return $this + */ #[Override] public function inspectErr(callable $fn): Result { @@ -112,24 +128,48 @@ public function inspectErr(callable $fn): Result return $this; } + /** + * @template U + * @template V + * + * @param U $default + * @param callable(never): V $fn + * + * @return U + */ #[Override] public function mapOr(mixed $default, callable $fn): mixed { return $default; } + /** + * @template U + * @template V + * + * @param callable(): U $default_fn + * @param callable(never): V $fn + * + * @return U + */ #[Override] public function mapOrElse(callable $default_fn, callable $fn): mixed { return $default_fn(); } + /** + * @return $this + */ #[Override] public function and(Result $res): Result { return $this; } + /** + * @return $this + */ #[Override] public function andThen(callable $fn): Result { diff --git a/src/Ok.php b/src/Ok.php index 89a894d..fea4b7b 100644 --- a/src/Ok.php +++ b/src/Ok.php @@ -85,18 +85,31 @@ public function unwrapOrElse(callable $fn): mixed return $this->value; } + /** + * @template U + * + * @param callable(T): U $fn + * + * @return Ok + */ #[Override] public function map(callable $fn): Result { return new self($fn($this->value)); } + /** + * @return $this + */ #[Override] public function mapErr(callable $fn): Result { return $this; } + /** + * @return $this + */ #[Override] public function inspect(callable $fn): Result { @@ -105,6 +118,9 @@ public function inspect(callable $fn): Result return $this; } + /** + * @return $this + */ #[Override] public function inspectErr(callable $fn): Result { @@ -113,8 +129,9 @@ public function inspectErr(callable $fn): Result /** * @template U + * @template V * - * @param U $default + * @param V $default * @param callable(T): U $fn * * @return U @@ -127,8 +144,9 @@ public function mapOr(mixed $default, callable $fn): mixed /** * @template U + * @template V * - * @param callable(): U $default_fn + * @param callable(): V $default_fn * @param callable(T): U $fn * * @return U @@ -151,12 +169,18 @@ public function andThen(callable $fn): Result return $fn($this->value); } + /** + * @return $this + */ #[Override] public function or(Result $res): Result { return $this; } + /** + * @return $this + */ #[Override] public function orElse(callable $fn): Result { diff --git a/tests/types/result.php b/tests/types/result.php index 1fc4964..fc4e0c4 100644 --- a/tests/types/result.php +++ b/tests/types/result.php @@ -123,6 +123,44 @@ function testExhaustiveMatch(Result $result): string }; } +/** + * 具象 Ok レシーバでは no-op 側のメソッドが実行時に起こり得ない型を混ぜない. + * + * @param Ok $ok + */ +function testConcreteOkPrecision(Ok $ok): void +{ + // or/orElse は $this を返すため Ok のまま + assertType('Valbeat\Result\Ok', $ok->orElse(static fn (never $e): Result => findNameById(0))); + assertType('Valbeat\Result\Ok', $ok->or(findNameById(0))); + // mapErr は no-op + assertType('Valbeat\Result\Ok', $ok->mapErr(static fn (never $e): LogicException => $e)); + // map は Ok を返す + assertType('Valbeat\Result\Ok', $ok->map(stringify(...))); + // mapOr/mapOrElse は常に $fn の結果(デフォルト値の型は混ざらない) + assertType('string', $ok->mapOr(0.5, stringify(...))); + assertType('string', $ok->mapOrElse(static fn (): float => 0.5, stringify(...))); +} + +/** + * 具象 Err レシーバでは no-op 側のメソッドが実行時に起こり得ない型を混ぜない. + * + * @param Err $err + */ +function testConcreteErrPrecision(Err $err, float $fallback): void +{ + // and/andThen は $this を返すため Err のまま + assertType('Valbeat\Result\Err', $err->andThen(static fn (never $v): Result => findNameById(0))); + assertType('Valbeat\Result\Err', $err->and(findNameById(0))); + // map は no-op + assertType('Valbeat\Result\Err', $err->map(static fn (never $v): int => $v)); + // mapErr は Err を返す + assertType('Valbeat\Result\Err', $err->mapErr(static fn (RuntimeException $e): LogicException => new LogicException($e->getMessage()))); + // mapOr/mapOrElse は常にデフォルト側($fn の戻り値型は混ざらない) + assertType('float', $err->mapOr($fallback, static fn (never $v): string => $v)); + assertType('float', $err->mapOrElse(static fn (): float => $fallback, static fn (never $v): string => $v)); +} + function stringify(int $value): string { return 'value: ' . $value; From f40431d6b0c04f3a3062979fc3207b492c7d0dae Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Sat, 6 Jun 2026 10:14:11 +0900 Subject: [PATCH 6/6] fix: address review comments for PR #76 - Rename tests/types/ to tests/Types/ to match the namespace casing (Copilot) - Make the README error-composition example runnable on PHP 8.4 (CodeRabbit) - Add a nested-Result flattening type test for andThen (CodeRabbit) --- README.md | 23 ++++++++++++++++------- tests/{types => Types}/result.php | 11 +++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) rename tests/{types => Types}/result.php (93%) diff --git a/README.md b/README.md index b8bf982..5f38f44 100644 --- a/README.md +++ b/README.md @@ -90,15 +90,24 @@ Each step in a chain may fail with a **different error type**. The error types are composed into a union, so PHPStan tracks every error the chain can produce: ```php -/** @return Result */ -function validateUserId(string $raw): Result { /* ... */ } +final class ValidationError {} +final class NotFoundError {} -/** @return Result */ -function findUserById(UserId $id): Result { /* ... */ } +/** @return Result */ +function validateUserId(string $raw): Result +{ + return ctype_digit($raw) ? new Ok((int) $raw) : new Err(new ValidationError()); +} + +/** @return Result */ +function findUserNameById(int $id): Result +{ + return $id === 42 ? new Ok('Alice') : new Err(new NotFoundError()); +} -// PHPStan infers Result -$user = validateUserId($request['id']) - ->andThen(findUserById(...)); +// PHPStan infers Result +$userName = validateUserId('42')->andThen(findUserNameById(...)); +echo $userName->unwrap(); // "Alice" ``` ### Combining Results diff --git a/tests/types/result.php b/tests/Types/result.php similarity index 93% rename from tests/types/result.php rename to tests/Types/result.php index fc4e0c4..9dc544e 100644 --- a/tests/types/result.php +++ b/tests/Types/result.php @@ -123,6 +123,17 @@ function testExhaustiveMatch(Result $result): string }; } +/** + * ネストした Result の平坦化: andThen が内側の成功型と両エラー型の合成を推論できる. + * + * @param Result, LogicException> $nested + */ +function testNestedResultFlattening(Result $nested): void +{ + $flattened = $nested->andThen(static fn (Result $inner): Result => $inner); + assertType('Valbeat\Result\Result', $flattened); +} + /** * 具象 Ok レシーバでは no-op 側のメソッドが実行時に起こり得ない型を混ぜない. *