Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Throwable> 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)]);
Comment on lines +144 to +150
echo implode(',', $result->unwrap()); // "1,2,3"

// Flatten a nested Result<Result<T, E2>, E1> into Result<T, E1|E2>
$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
Expand Down Expand Up @@ -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<Result<T, E>>` into `Result<list<T>, E>`, short-circuiting on the first Err
- `Results::flatten(Result $result): Result` - Flattens `Result<Result<T, E2>, E1>` into `Result<T, E1|E2>`

## License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
Expand Down
86 changes: 86 additions & 0 deletions src/Results.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace Valbeat\Result;

/**
* Result を生成・合成する静的ヘルパーです.
*/
final class Results
{
/**
* 静的ヘルパーのためインスタンス化を禁止します.
*
* @codeCoverageIgnore
*/
private function __construct()
{
}

/**
* 例外を投げうる処理を実行し、結果を Result に包みます.
*
* 成功時は戻り値を Ok に、\Throwable が送出された場合は Err に包んで返します.
* 例外ベースの既存コードを Result の世界に持ち込む入口として使います.
*
* @template T
*
* @param callable(): T $fn
*
* @return Result<T, \Throwable>
*/
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<Result<T, E>> $results
*
* @return Result<list<T>, 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<U, F> に分解できない(infer がない)ため。静的ヘルパーなら
* パラメータ側のテンプレートで内側の型を正確に推論できます.
*
* @template T
* @template E1
* @template E2
*
* @param Result<Result<T, E2>, E1> $result
*
* @return Result<T, E1|E2>
*/
public static function flatten(Result $result): Result
{
return $result->andThen(static fn (Result $inner): Result => $inner);
}
Comment on lines +82 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Results::flatten は現在 andThen とクロージャを使用して実装されていますが、これだと呼び出しのたびにクロージャの生成と呼び出しのオーバーヘッドが発生します。

以下のように isOk()unwrap() を使用して直接インナーの Result を返すようにすることで、クロージャの割り当てを回避し、パフォーマンスを向上させることができます。

    public static function flatten(Result $result): Result
    {
        if ($result->isOk()) {
            return $result->unwrap();
        }

        return $result;
    }

}
116 changes: 116 additions & 0 deletions tests/ResultsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

declare(strict_types=1);

namespace Valbeat\Result\Tests;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Valbeat\Result\Err;
use Valbeat\Result\Ok;
use Valbeat\Result\Result;
use Valbeat\Result\Results;

class ResultsTest extends TestCase
{
#[Test]
public function try_whenCallableSucceeds_returns_ok(): void
{
$result = Results::try(fn () => 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<Result<int, string>> $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<Result<int, string>, string> に widening するためのヘルパ.
*
* @param Result<Result<int, string>, string> $result
*
* @return Result<Result<int, string>, string>
*/
private static function asNestedResult(Result $result): Result
{
return $result;
}
}
29 changes: 29 additions & 0 deletions tests/Types/result.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Valbeat\Result\Ok;

use Valbeat\Result\Result;
use Valbeat\Result\Results;

/**
* 共変性のテスト: Ok<int> (= Result<int, never>) を
Expand Down Expand Up @@ -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<int, Throwable>', Results::try(static fn (): int => 42));
}

/**
* Results::combine は iterable<Result<T, E>> から Result<list<T>, E> を推論する.
*
* @param list<Result<int, RuntimeException>> $results
*/
function testCombineInference(array $results): void
{
assertType('Valbeat\Result\Result<list<int>, RuntimeException>', Results::combine($results));
}

/**
* Results::flatten はネストした Result の内側の成功型と両エラー型の合成を推論する.
*
* @param Result<Result<int, RuntimeException>, LogicException> $nested
*/
function testFlattenInference(Result $nested): void
{
assertType('Valbeat\Result\Result<int, LogicException|RuntimeException>', Results::flatten($nested));
}
Loading