From 1373b8b325a9bbfe6da53c1987609591638917ef Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Tue, 7 Jul 2026 13:13:05 +0900 Subject: [PATCH 1/3] test: pin Results helper (try / combine / flatten) - Results::try(callable): run exception-throwing code and wrap the outcome (Ok on success, Err on throw, including Errors) - Results::combine(iterable>): Result, E>, short-circuiting on the first Err - Results::flatten(Result,E1>): Result flatten is a static helper rather than an instance method because PHPStan cannot destructure a template (no 'infer' in conditional types), while @param templates on a static type precisely. Currently RED: the Results class does not exist yet. Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK --- tests/ResultsTest.php | 101 ++++++++++++++++++++++++++++++++++++++++ tests/Types/results.php | 42 +++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 tests/ResultsTest.php create mode 100644 tests/Types/results.php diff --git a/tests/ResultsTest.php b/tests/ResultsTest.php new file mode 100644 index 0000000..6610812 --- /dev/null +++ b/tests/ResultsTest.php @@ -0,0 +1,101 @@ + 42); + $this->assertInstanceOf(Ok::class, $result); + $this->assertSame(42, $result->unwrap()); + } + + #[Test] + public function try_whenCallableThrows_returns_err_with_exception(): void + { + $exception = new \RuntimeException('boom'); + $result = Results::try(function () use ($exception): int { + throw $exception; + }); + $this->assertInstanceOf(Err::class, $result); + $this->assertSame($exception, $result->unwrapErr()); + } + + #[Test] + public function try_catches_errors_not_only_exceptions(): void + { + $result = Results::try(fn () => intdiv(1, 0)); + $this->assertInstanceOf(Err::class, $result); + $this->assertInstanceOf(\DivisionByZeroError::class, $result->unwrapErr()); + } + + #[Test] + public function combine_allOk_returns_ok_with_values_in_order(): void + { + $result = Results::combine([new Ok(1), new Ok(2), new Ok(3)]); + $this->assertInstanceOf(Ok::class, $result); + $this->assertSame([1, 2, 3], $result->unwrap()); + } + + #[Test] + public function combine_withErr_returns_first_err(): void + { + $firstErr = new Err('first error'); + $result = Results::combine([new Ok(1), $firstErr, new Ok(3), new Err('second error')]); + $this->assertSame($firstErr, $result); + } + + #[Test] + public function combine_withEmptyIterable_returns_ok_with_empty_array(): void + { + $result = Results::combine([]); + $this->assertInstanceOf(Ok::class, $result); + $this->assertSame([], $result->unwrap()); + } + + #[Test] + public function combine_acceptsGenerator(): void + { + $results = (static function (): \Generator { + yield new Ok('a'); + yield new Ok('b'); + })(); + $result = Results::combine($results); + $this->assertInstanceOf(Ok::class, $result); + $this->assertSame(['a', 'b'], $result->unwrap()); + } + + #[Test] + public function flatten_okOfOk_returns_inner_ok(): void + { + $inner = new Ok(42); + $result = Results::flatten(new Ok($inner)); + $this->assertSame($inner, $result); + } + + #[Test] + public function flatten_okOfErr_returns_inner_err(): void + { + $inner = new Err('inner error'); + $result = Results::flatten(new Ok($inner)); + $this->assertSame($inner, $result); + } + + #[Test] + public function flatten_err_returns_outer_err(): void + { + $outer = new Err('outer error'); + $result = Results::flatten($outer); + $this->assertSame($outer, $result); + } +} diff --git a/tests/Types/results.php b/tests/Types/results.php new file mode 100644 index 0000000..ea7eeed --- /dev/null +++ b/tests/Types/results.php @@ -0,0 +1,42 @@ +', Results::try(static fn (): int => 42)); +} + +/** + * Results::combine は iterable> から Result, E> を推論する. + * + * @param list> $results + */ +function testCombineInference(array $results): void +{ + assertType('Valbeat\Result\Result, RuntimeException>', Results::combine($results)); +} + +/** + * Results::flatten はネストした Result の内側の成功型と両エラー型の合成を推論する. + * + * @param Result, LogicException> $nested + */ +function testFlattenInference(Result $nested): void +{ + assertType('Valbeat\Result\Result', Results::flatten($nested)); +} From 8282fbf978d10dd6403f089edbb14c6c9bb48d37 Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Tue, 7 Jul 2026 13:15:57 +0900 Subject: [PATCH 2/3] feat: add Results helper class (try / combine / flatten) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Results::try(callable): bridge from exception-throwing code into the Result world — Ok with the return value, or Err wrapping the thrown Throwable (Errors included) - Results::combine(iterable>): Result, E> with first-Err short-circuit, for validation-style aggregation - Results::flatten(Result,E1>): Result These are static helpers because PHP interfaces cannot carry implementations and PHPStan conditional types cannot destructure a template parameter (no infer), whereas @param templates on statics give full inference. Type-level behavior is pinned with assertType tests alongside the runtime tests. Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK --- README.md | 22 ++++++++++++ src/Results.php | 81 +++++++++++++++++++++++++++++++++++++++++++ tests/ResultsTest.php | 19 ++++++++-- 3 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 src/Results.php diff --git a/README.md b/README.md index f28649f..4e6f8a7 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,23 @@ $result = (new Err("oops")) ->inspectErr(fn($e) => error_log("Error occurred: $e")); ``` +### Helpers (Results class) + +```php +use Valbeat\Result\Results; + +// Wrap exception-throwing code: Ok on success, Err on throw +$result = Results::try(fn() => json_decode($raw, flags: JSON_THROW_ON_ERROR)); + +// Combine many Results: Ok with all values, or the first Err +$result = Results::combine([new Ok(1), new Ok(2), new Ok(3)]); +echo implode(',', $result->unwrap()); // "1,2,3" + +// Flatten a nested Result, E1> into Result +$result = Results::flatten(new Ok(new Ok(42))); +echo $result->unwrap(); // 42 +``` + ## Type Safety This library is designed to be used with [PHPStan](https://phpstan.org/) at level max @@ -226,6 +243,11 @@ All Result types (both Ok and Err) implement these methods: #### Pattern Matching - `match(callable $okFn, callable $errFn): mixed` - Pattern match on the Result +### Results Helpers (static) +- `Results::try(callable $fn): Result` - Runs a callable and wraps the outcome: Ok with the return value, or Err with the thrown Throwable +- `Results::combine(iterable $results): Result` - Combines `iterable>` into `Result, E>`, short-circuiting on the first Err +- `Results::flatten(Result $result): Result` - Flattens `Result, E1>` into `Result` + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/src/Results.php b/src/Results.php new file mode 100644 index 0000000..0d97ad1 --- /dev/null +++ b/src/Results.php @@ -0,0 +1,81 @@ + + */ + public static function try(callable $fn): Result + { + try { + return new Ok($fn()); + } catch (\Throwable $e) { + return new Err($e); + } + } + + /** + * 複数の Result を 1 つに合成します. + * + * すべて成功なら値のリストを Ok で返し、失敗が含まれる場合は最初の Err を返します. + * + * @template T + * @template E + * + * @param iterable> $results + * + * @return Result, E> + */ + public static function combine(iterable $results): Result + { + $values = []; + foreach ($results as $result) { + if ($result->isErr()) { + return $result; + } + $values[] = $result->unwrap(); + } + + return new Ok($values); + } + + /** + * ネストした Result を 1 段平坦化します. + * + * インスタンスメソッドにしないのは、PHPStan の条件型ではテンプレート T を + * Result に分解できない(infer がない)ため。静的ヘルパーなら + * パラメータ側のテンプレートで内側の型を正確に推論できます. + * + * @template T + * @template E1 + * @template E2 + * + * @param Result, E1> $result + * + * @return Result + */ + public static function flatten(Result $result): Result + { + return $result->andThen(static fn (Result $inner): Result => $inner); + } +} diff --git a/tests/ResultsTest.php b/tests/ResultsTest.php index 6610812..ad4463d 100644 --- a/tests/ResultsTest.php +++ b/tests/ResultsTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use Valbeat\Result\Err; use Valbeat\Result\Ok; +use Valbeat\Result\Result; use Valbeat\Result\Results; class ResultsTest extends TestCase @@ -58,7 +59,9 @@ public function combine_withErr_returns_first_err(): void #[Test] public function combine_withEmptyIterable_returns_ok_with_empty_array(): void { - $result = Results::combine([]); + /** @var list> $results */ + $results = []; + $result = Results::combine($results); $this->assertInstanceOf(Ok::class, $result); $this->assertSame([], $result->unwrap()); } @@ -94,8 +97,20 @@ public function flatten_okOfErr_returns_inner_err(): void #[Test] public function flatten_err_returns_outer_err(): void { - $outer = new Err('outer error'); + $outer = self::asNestedResult(new Err('outer error')); $result = Results::flatten($outer); $this->assertSame($outer, $result); } + + /** + * リテラル型を Result, string> に widening するためのヘルパ. + * + * @param Result, string> $result + * + * @return Result, string> + */ + private static function asNestedResult(Result $result): Result + { + return $result; + } } From 1d5de98a00ad02a5699dc572270a415847c38bc1 Mon Sep 17 00:00:00 2001 From: Takuma Kajikawa Date: Tue, 7 Jul 2026 13:49:37 +0900 Subject: [PATCH 3/3] refactor: fold type tests into result.php and exclude private ctor from coverage Review feedback: - tests/Types/results.php duplicated the role of the existing tests/Types/result.php (same namespace, same purpose); fold the three assertType functions into the existing file per the edit-over-create convention - the never-executed private constructor was the one uncovered diff line failing the codecov patch (92.85% < 100%) and project checks; mark it @codeCoverageIgnore Claude-Session: https://claude.ai/code/session_017XTM7pxbWPVNLV639i5WgK --- src/Results.php | 5 +++++ tests/Types/result.php | 29 ++++++++++++++++++++++++++++ tests/Types/results.php | 42 ----------------------------------------- 3 files changed, 34 insertions(+), 42 deletions(-) delete mode 100644 tests/Types/results.php diff --git a/src/Results.php b/src/Results.php index 0d97ad1..71f1f5f 100644 --- a/src/Results.php +++ b/src/Results.php @@ -9,6 +9,11 @@ */ final class Results { + /** + * 静的ヘルパーのためインスタンス化を禁止します. + * + * @codeCoverageIgnore + */ private function __construct() { } diff --git a/tests/Types/result.php b/tests/Types/result.php index 1ba6366..f68bce1 100644 --- a/tests/Types/result.php +++ b/tests/Types/result.php @@ -13,6 +13,7 @@ use Valbeat\Result\Ok; use Valbeat\Result\Result; +use Valbeat\Result\Results; /** * 共変性のテスト: Ok (= Result) を @@ -443,3 +444,31 @@ function testInstanceofErrLosesSealedErrorType(Result $result): void assertType('mixed', $result->unwrapErr()); } } + +/** + * Results::try は戻り値の型を Ok 側に、送出されうる例外を Throwable として Err 側に推論する. + */ +function testTryInference(): void +{ + assertType('Valbeat\Result\Result', Results::try(static fn (): int => 42)); +} + +/** + * Results::combine は iterable> から Result, E> を推論する. + * + * @param list> $results + */ +function testCombineInference(array $results): void +{ + assertType('Valbeat\Result\Result, RuntimeException>', Results::combine($results)); +} + +/** + * Results::flatten はネストした Result の内側の成功型と両エラー型の合成を推論する. + * + * @param Result, LogicException> $nested + */ +function testFlattenInference(Result $nested): void +{ + assertType('Valbeat\Result\Result', Results::flatten($nested)); +} diff --git a/tests/Types/results.php b/tests/Types/results.php deleted file mode 100644 index ea7eeed..0000000 --- a/tests/Types/results.php +++ /dev/null @@ -1,42 +0,0 @@ -', Results::try(static fn (): int => 42)); -} - -/** - * Results::combine は iterable> から Result, E> を推論する. - * - * @param list> $results - */ -function testCombineInference(array $results): void -{ - assertType('Valbeat\Result\Result, RuntimeException>', Results::combine($results)); -} - -/** - * Results::flatten はネストした Result の内側の成功型と両エラー型の合成を推論する. - * - * @param Result, LogicException> $nested - */ -function testFlattenInference(Result $nested): void -{ - assertType('Valbeat\Result\Result', Results::flatten($nested)); -}