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..71f1f5f --- /dev/null +++ b/src/Results.php @@ -0,0 +1,86 @@ + + */ + 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 new file mode 100644 index 0000000..ad4463d --- /dev/null +++ b/tests/ResultsTest.php @@ -0,0 +1,116 @@ + 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 + { + /** @var list> $results */ + $results = []; + $result = Results::combine($results); + $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 = 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; + } +} 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)); +}