From 79cb39720634298c45e47caedf6aedffe394104f Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 28 Dec 2024 21:39:10 +0800 Subject: [PATCH 1/8] Add README.md --- src/Nexus/Result/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/Nexus/Result/README.md diff --git a/src/Nexus/Result/README.md b/src/Nexus/Result/README.md new file mode 100644 index 0000000..030c3bb --- /dev/null +++ b/src/Nexus/Result/README.md @@ -0,0 +1,25 @@ +# Nexus Result + +Nexus Result implements Rust's [Result type][5] into PHP. + +Result is a type that represents either success (`Ok`) or failure (`Err`). + +## Installation + + composer require nexusphp/result + +## Getting Started + +## License + +Nexus Result is licensed under the [MIT License][1]. + +## Resources + +* [Report issues][2] and [send pull requests][3] in the [main Nexus repository][4] + +[1]: LICENSE +[2]: https://github.com/NexusPHP/framework/issues +[3]: https://github.com/NexusPHP/framework/pulls +[4]: https://github.com/NexusPHP/framework +[5]: https://doc.rust-lang.org/std/result/enum.Result.html From 98f92b4ab01850979ed70ea3767378b6b0012cc3 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 28 Dec 2024 21:39:23 +0800 Subject: [PATCH 2/8] Add LICENSE --- src/Nexus/Result/LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/Nexus/Result/LICENSE diff --git a/src/Nexus/Result/LICENSE b/src/Nexus/Result/LICENSE new file mode 100644 index 0000000..ce701d4 --- /dev/null +++ b/src/Nexus/Result/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 John Paul E. Balandan, CPA + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 7876b557baff6c6236908d636b61b0e2af4c0a58 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 28 Dec 2024 21:39:39 +0800 Subject: [PATCH 3/8] Add composer.json --- composer.json | 1 + src/Nexus/Result/composer.json | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/Nexus/Result/composer.json diff --git a/composer.json b/composer.json index 746f90c..3534d9f 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "nexusphp/option": "self.version", "nexusphp/password": "self.version", "nexusphp/phpstan-nexus": "self.version", + "nexusphp/result": "self.version", "nexusphp/suppression": "self.version" }, "provide": { diff --git a/src/Nexus/Result/composer.json b/src/Nexus/Result/composer.json new file mode 100644 index 0000000..a6cb779 --- /dev/null +++ b/src/Nexus/Result/composer.json @@ -0,0 +1,35 @@ +{ + "name": "nexusphp/result", + "description": "The Nexus Result library.", + "license": "MIT", + "type": "library", + "keywords": [ + "nexus", + "result" + ], + "authors": [ + { + "name": "John Paul E. Balandan, CPA", + "email": "paulbalandan@gmail.com" + } + ], + "support": { + "issues": "https://github.com/NexusPHP/framework/issues", + "source": "https://github.com/NexusPHP/framework" + }, + "require": { + "php": "^8.3" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "psr-4": { + "Nexus\\Result\\": "" + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + } +} From 014130347e4d69fd1dfefbfabd680aa52c54b4c9 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sat, 28 Dec 2024 23:23:11 +0800 Subject: [PATCH 4/8] Add Result interface --- src/Nexus/Result/Result.php | 211 ++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 src/Nexus/Result/Result.php diff --git a/src/Nexus/Result/Result.php b/src/Nexus/Result/Result.php new file mode 100644 index 0000000..2d94c96 --- /dev/null +++ b/src/Nexus/Result/Result.php @@ -0,0 +1,211 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Result; + +/** + * A PHP implementation of Rust's Result enum. + * + * `Result` is the type used for returning and propagating errors. It is + * an enum with the variants, `Ok(T)`, representing success and containing a + * value, and `Err(E)`, representing error and containing an error value. + * + * @template T + * @template E + * + * @see https://doc.rust-lang.org/std/result/enum.Result.html + */ +interface Result +{ + /** + * Returns `true` if the result is `Ok`. + */ + public function isOk(): bool; + + /** + * Returns `true` if the result is `Ok` and the value inside of it matches a predicate. + * + * @param (\Closure(T): bool) $predicate + */ + public function isOkAnd(\Closure $predicate): bool; + + /** + * Returns `true` if the result is `Err`. + */ + public function isErr(): bool; + + /** + * Returns `true` if the result is `Err` and the value inside of it matches a predicate. + * + * @param (\Closure(E): bool) $predicate + */ + public function isErrAnd(\Closure $predicate): bool; + + /** + * Maps a `Result` to `Result` by applying a function to a + * contained `Ok` value, leaving an `Err` value untouched. + * + * @template U + * + * @param (\Closure(T): U) $predicate + * + * @return self + */ + public function map(\Closure $predicate): self; + + /** + * Returns the provided default (if `Err`), or applies a function to the contained value + * (if `Ok`). + * + * Arguments passed to `Result::mapOr()` are eagerly evaluated; if you are passing the result + * of a method call, it is recommended to use `Result::mapOrElse()`, which is lazily + * evaluated. + * + * @template U + * + * @param U $default + * @param (\Closure(T): U) $predicate + * + * @return U + */ + public function mapOr(mixed $default, \Closure $predicate): mixed; + + /** + * Maps a `Result` to `U` by applying fallback function `$default` to a contained + * `Err` value, or function `$predicate` to a contained `Ok` value. + * + * This method can be used to unpack a successful result while handling an error. + * + * @template U + * + * @param (\Closure(E): U) $default + * @param (\Closure(T): U) $predicate + * + * @return U + */ + public function mapOrElse(\Closure $default, \Closure $predicate): mixed; + + /** + * Maps a `Result` to `Result` by applying a function to a contained + * `Err` value, leaving an `Ok` value untouched. + * + * This method can be used to pass through a successful result while handling an error. + * + * @template F + * + * @param (\Closure(E): F) $predicate + * + * @return self + */ + public function mapErr(\Closure $predicate): self; + + /** + * Returns the contained `Ok` value. + * + * Because this method may throw, its use is generally discouraged. Instead, prefer to + * use pattern matching and handle the `Err` case explicitly, or call `Result::unwrapOr()`, + * or `Result::unwrapOrElse()`. + * + * @return T + */ + public function unwrap(): mixed; + + /** + * Returns the contained `Ok` value or a provided `$default`. + * + * Arguments passed to `Result::unwrapOr()` are eagerly evaluated; if you are passing + * the result of a method call, it is recommended to use `Result::unwrapOrElse()`, + * which is lazily evaluated. + * + * @param T $default + * + * @return T + */ + public function unwrapOr(mixed $default): mixed; + + /** + * Returns the contained `Ok` value or computes it from a closure. + * + * @param (\Closure(E): T) $op + * + * @return T + */ + public function unwrapOrElse(\Closure $op): mixed; + + /** + * Returns the contained `Err` value. + * + * Throws if the value is an `Ok`, with a custom exception message + * provided by the `Ok`’s value. + * + * @return E + */ + public function unwrapErr(): mixed; + + /** + * Returns `$res` if the result is `Ok`, otherwise returns the `Err` value of self. + * + * Arguments passed to `Result::and()` are eagerly evaluated; if you are passing the + * result of a method call, it is recommended to use `Result::andThen()`, which is + * lazily evaluated. + * + * @template U + * + * @param self $res + * + * @return self + */ + public function and(self $res): self; + + /** + * Calls `$op` if the result is `Ok`, otherwise returns the `Err` value of self. + * + * This method can be used for control flow based on `Result` values. Often used to chain + * fallible operations that may return `Err`. + * + * @template U + * + * @param (\Closure(T): self) $op + * + * @return self + */ + public function andThen(\Closure $op): self; + + /** + * Returns `$res` if the result is `Err`, otherwise returns the `Ok` value of self. + * + * Arguments passed to `Result::or()` are eagerly evaluated; if you are passing the + * result of a method call, it is recommended to use `Result::orElse()`, which is + * lazily evaluated. + * + * @template F + * + * @param self $res + * + * @return self + */ + public function or(self $res): self; + + /** + * Calls `$op` if the result is `Err`, otherwise returns the `Ok` value of self. + * + * This method can be used for control flow based on result values. + * + * @template F + * + * @param (\Closure(E): self) $op + * + * @return self + */ + public function orElse(\Closure $op): self; +} From 6cd96c136d0453938c8f200e74e01beb1654cf84 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Tue, 31 Dec 2024 01:20:15 +0800 Subject: [PATCH 5/8] Add UnwrappedResultException --- src/Nexus/Result/Result.php | 4 ++++ src/Nexus/Result/UnwrappedResultException.php | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 src/Nexus/Result/UnwrappedResultException.php diff --git a/src/Nexus/Result/Result.php b/src/Nexus/Result/Result.php index 2d94c96..7b3c44d 100644 --- a/src/Nexus/Result/Result.php +++ b/src/Nexus/Result/Result.php @@ -117,6 +117,8 @@ public function mapErr(\Closure $predicate): self; * or `Result::unwrapOrElse()`. * * @return T + * + * @throws UnwrappedResultException if result is `Err` */ public function unwrap(): mixed; @@ -149,6 +151,8 @@ public function unwrapOrElse(\Closure $op): mixed; * provided by the `Ok`’s value. * * @return E + * + * @throws UnwrappedResultException if result is `Ok` */ public function unwrapErr(): mixed; diff --git a/src/Nexus/Result/UnwrappedResultException.php b/src/Nexus/Result/UnwrappedResultException.php new file mode 100644 index 0000000..fc69af7 --- /dev/null +++ b/src/Nexus/Result/UnwrappedResultException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Result; + +final class UnwrappedResultException extends \RuntimeException {} From 815f5ca42cf22d08732d257ecea024a6753606ce Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 1 Jan 2025 02:26:46 +0800 Subject: [PATCH 6/8] Add `Ok` and `Err` classes --- src/Nexus/Result/Err.php | 158 +++++++++++++++++++ src/Nexus/Result/Ok.php | 154 +++++++++++++++++++ src/Nexus/Result/Result.php | 18 ++- tests/AutoReview/TestCodeTest.php | 4 + tests/Result/ResultTest.php | 246 ++++++++++++++++++++++++++++++ 5 files changed, 576 insertions(+), 4 deletions(-) create mode 100644 src/Nexus/Result/Err.php create mode 100644 src/Nexus/Result/Ok.php create mode 100644 tests/Result/ResultTest.php diff --git a/src/Nexus/Result/Err.php b/src/Nexus/Result/Err.php new file mode 100644 index 0000000..d4f510c --- /dev/null +++ b/src/Nexus/Result/Err.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Result; + +/** + * @template E + * + * @implements Result + */ +final readonly class Err implements Result +{ + /** + * @param E $err + */ + public function __construct( + private mixed $err, + ) {} + + public function isOk(): bool + { + return false; + } + + public function isOkAnd(\Closure $predicate): bool + { + return false; + } + + public function isErr(): bool + { + return true; + } + + public function isErrAnd(\Closure $predicate): bool + { + return $predicate($this->err); + } + + /** + * @return self + */ + public function map(\Closure $predicate): self + { + return $this; + } + + public function mapOr(mixed $default, \Closure $predicate): mixed + { + return $default; + } + + public function mapOrElse(\Closure $default, \Closure $predicate): mixed + { + return $default($this->err); + } + + public function mapErr(\Closure $predicate): Result + { + return new self($predicate($this->err)); + } + + public function unwrap(): never + { + $message = static fn(string $arg): string => \sprintf('Unwrapped an Err result: %s', $arg); + + if ($this->err instanceof \Throwable) { + throw new UnwrappedResultException($message($this->err->getMessage()), 0, $this->err); + } + + if (\is_scalar($this->err)) { + throw new UnwrappedResultException($message(var_export($this->err, true))); + } + + throw new UnwrappedResultException('Unwrapped an Err result.'); + } + + /** + * @template U + * + * @param U $default + * + * @return U + */ + public function unwrapOr(mixed $default): mixed + { + return $default; + } + + /** + * @template U + * + * @param (\Closure(E): U) $op + * + * @return U + */ + public function unwrapOrElse(\Closure $op): mixed + { + return $op($this->err); + } + + public function unwrapErr(): mixed + { + return $this->err; + } + + /** + * @return self + */ + public function and(Result $res): self + { + return $this; + } + + /** + * @return self + */ + public function andThen(\Closure $op): self + { + return $this; + } + + /** + * @template T + * @template F + * + * @param Result $res + * + * @return Result + */ + public function or(Result $res): Result + { + return $res; + } + + /** + * @template T + * @template F + * + * @param (\Closure(E): Result) $op + * + * @return Result + */ + public function orElse(\Closure $op): Result + { + return $op($this->err); + } +} diff --git a/src/Nexus/Result/Ok.php b/src/Nexus/Result/Ok.php new file mode 100644 index 0000000..7bb0481 --- /dev/null +++ b/src/Nexus/Result/Ok.php @@ -0,0 +1,154 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Result; + +/** + * @template T + * + * @implements Result + */ +final readonly class Ok implements Result +{ + /** + * @param T $value + */ + public function __construct( + private mixed $value, + ) {} + + public function isOk(): bool + { + return true; + } + + public function isOkAnd(\Closure $predicate): bool + { + return $predicate($this->value); + } + + public function isErr(): bool + { + return false; + } + + public function isErrAnd(\Closure $predicate): bool + { + return false; + } + + /** + * @template U + * + * @param (\Closure(T): U) $predicate + * + * @return self + */ + public function map(\Closure $predicate): self + { + return new self($predicate($this->value)); + } + + public function mapOr(mixed $default, \Closure $predicate): mixed + { + return $predicate($this->value); + } + + public function mapOrElse(\Closure $default, \Closure $predicate): mixed + { + return $predicate($this->value); + } + + public function mapErr(\Closure $predicate): Result + { + return $this; + } + + public function unwrap(): mixed + { + return $this->value; + } + + /** + * @return T + */ + public function unwrapOr(mixed $default): mixed + { + return $this->value; + } + + /** + * @return T + */ + public function unwrapOrElse(\Closure $op): mixed + { + return $this->value; + } + + public function unwrapErr(): never + { + $message = static fn(string $arg): string => \sprintf('Unwrapped an Ok result: %s', $arg); + + if ($this->value instanceof \Throwable) { + throw new UnwrappedResultException($message($this->value->getMessage()), 0, $this->value); + } + + if (\is_scalar($this->value)) { + throw new UnwrappedResultException($message(var_export($this->value, true))); + } + + throw new UnwrappedResultException('Unwrapped an Ok result.'); + } + + /** + * @template U + * @template E + * + * @param Result $res + * + * @return Result + */ + public function and(Result $res): Result + { + return $res; + } + + /** + * @template U + * @template E + * + * @param (\Closure(T): Result) $op + * + * @return Result + */ + public function andThen(\Closure $op): Result + { + return $op($this->value); + } + + /** + * @return self + */ + public function or(Result $res): self + { + return $this; + } + + /** + * @return self + */ + public function orElse(\Closure $op): self + { + return $this; + } +} diff --git a/src/Nexus/Result/Result.php b/src/Nexus/Result/Result.php index 7b3c44d..cd6d11a 100644 --- a/src/Nexus/Result/Result.php +++ b/src/Nexus/Result/Result.php @@ -29,6 +29,9 @@ interface Result { /** * Returns `true` if the result is `Ok`. + * + * @phpstan-assert-if-true Ok $this + * @phpstan-assert-if-false Err $this */ public function isOk(): bool; @@ -41,6 +44,9 @@ public function isOkAnd(\Closure $predicate): bool; /** * Returns `true` if the result is `Err`. + * + * @phpstan-assert-if-true Err $this + * @phpstan-assert-if-false Ok $this */ public function isErr(): bool; @@ -129,18 +135,22 @@ public function unwrap(): mixed; * the result of a method call, it is recommended to use `Result::unwrapOrElse()`, * which is lazily evaluated. * - * @param T $default + * @template U * - * @return T + * @param U $default + * + * @return T|U */ public function unwrapOr(mixed $default): mixed; /** * Returns the contained `Ok` value or computes it from a closure. * - * @param (\Closure(E): T) $op + * @template U + * + * @param (\Closure(E): U) $op * - * @return T + * @return T|U */ public function unwrapOrElse(\Closure $op): mixed; diff --git a/tests/AutoReview/TestCodeTest.php b/tests/AutoReview/TestCodeTest.php index 55228fe..961a6ba 100644 --- a/tests/AutoReview/TestCodeTest.php +++ b/tests/AutoReview/TestCodeTest.php @@ -16,7 +16,10 @@ use Nexus\Collection\Collection; use Nexus\Option\None; use Nexus\Option\Some; +use Nexus\Result\Err; +use Nexus\Result\Ok; use Nexus\Tests\Option\OptionTest; +use Nexus\Tests\Result\ResultTest; use PHPStan\Testing\TypeInferenceTestCase; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversFunction; @@ -51,6 +54,7 @@ final class TestCodeTest extends TestCase */ private const TEST_CLASSES_COVERS = [ OptionTest::class => [None::class, Some::class], + ResultTest::class => [Err::class, Ok::class], ]; /** diff --git a/tests/Result/ResultTest.php b/tests/Result/ResultTest.php new file mode 100644 index 0000000..094cdee --- /dev/null +++ b/tests/Result/ResultTest.php @@ -0,0 +1,246 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Result; + +use Nexus\Result\Err; +use Nexus\Result\Ok; +use Nexus\Result\Result; +use Nexus\Result\UnwrappedResultException; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversClass(Ok::class)] +#[CoversClass(Err::class)] +#[Group('unit-test')] +final class ResultTest extends TestCase +{ + public function testResultIsOk(): void + { + self::assertTrue((new Ok(-3))->isOk()); + self::assertFalse((new Err('some error'))->isOk()); + } + + public function testResultIsOkAnd(): void + { + $predicate = static fn(int $v): bool => $v > 1; + + self::assertTrue((new Ok(2))->isOkAnd($predicate)); + self::assertFalse((new Ok(0))->isOkAnd($predicate)); + self::assertFalse((new Err('hey'))->isOkAnd($predicate)); + } + + public function testResultIsErr(): void + { + self::assertFalse((new Ok(-3))->isErr()); + self::assertTrue((new Err('some err'))->isErr()); + } + + public function testResultIsErrAnd(): void + { + $predicate = static fn(string $v): bool => \strlen($v) > 4; + + self::assertFalse((new Ok('heart'))->isErrAnd($predicate)); + self::assertFalse((new Err('hey'))->isErrAnd($predicate)); + self::assertTrue((new Err('heart'))->isErrAnd($predicate)); + } + + public function testResultMap(): void + { + $predicate = static fn(string $v): int => (int) $v * 2; + $err = new Err('some error'); + + self::assertSame(2, (new Ok('1'))->map($predicate)->unwrap()); + self::assertSame($err, $err->map($predicate)); + } + + public function testResultMapOr(): void + { + $predicate = static fn(string $v): int => \strlen($v); + $default = 42; + + self::assertSame(3, (new Ok('foo'))->mapOr($default, $predicate)); + self::assertSame($default, (new Err('bar'))->mapOr($default, $predicate)); + } + + public function testResultMapOrElse(): void + { + $default = static fn(string $e): int => 42; + $predicate = static fn(string $v): int => \strlen($v); + + self::assertSame(3, (new Ok('foo'))->mapOrElse($default, $predicate)); + self::assertSame(42, (new Err('bar'))->mapOrElse($default, $predicate)); + } + + public function testResultMapErr(): void + { + $predicate = static fn(int $code): string => \sprintf('error code: %d', $code); + $ok = new Ok(2); + + self::assertSame($ok, $ok->mapErr($predicate)); + self::assertSame('error code: 500', (new Err(500))->mapErr($predicate)->unwrapErr()); + } + + #[DataProvider('provideResultUnwrapCases')] + public function testResultUnwrap(mixed $err, string $message): void + { + self::assertSame(2, (new Ok(2))->unwrap()); + + $this->expectException(UnwrappedResultException::class); + $this->expectExceptionMessage($message); + (new Err($err))->unwrap(); + } + + /** + * @return iterable + */ + public static function provideResultUnwrapCases(): iterable + { + yield 'exception' => [new \RuntimeException('hello'), 'Unwrapped an Err result: hello']; + + yield 'error' => [new \Error('error message'), 'Unwrapped an Err result: error message']; + + yield 'string' => ['string message', 'Unwrapped an Err result: \'string message\'']; + + yield 'int' => [42, 'Unwrapped an Err result: 42']; + + yield 'float' => [200.0, 'Unwrapped an Err result: 200.0']; + + yield 'true' => [true, 'Unwrapped an Err result: true']; + + yield 'false' => [false, 'Unwrapped an Err result: false']; + + yield 'array' => [[], 'Unwrapped an Err result.']; + + yield 'class' => [[], 'Unwrapped an Err result.']; + } + + public function testResultUnwrapOr(): void + { + $default = 2; + + self::assertSame(9, (new Ok(9))->unwrapOr($default)); + self::assertSame($default, (new Err('error'))->unwrapOr($default)); + } + + public function testResultUnwrapOrElse(): void + { + $predicate = static fn(string $v): int => \strlen($v); + + self::assertSame(2, (new Ok(2))->unwrapOrElse($predicate)); + self::assertSame(3, (new Err('foo'))->unwrapOrElse($predicate)); + } + + #[DataProvider('provideResultUnwrapErrCases')] + public function testResultUnwrapErr(mixed $value, string $message): void + { + self::assertSame('hey', (new Err('hey'))->unwrapErr()); + + $this->expectException(UnwrappedResultException::class); + $this->expectExceptionMessage($message); + (new Ok($value))->unwrapErr(); + } + + /** + * @return iterable + */ + public static function provideResultUnwrapErrCases(): iterable + { + yield 'exception' => [new \RuntimeException('hello'), 'Unwrapped an Ok result: hello']; + + yield 'error' => [new \Error('message'), 'Unwrapped an Ok result: message']; + + yield 'string' => ['string message', 'Unwrapped an Ok result: \'string message\'']; + + yield 'int' => [42, 'Unwrapped an Ok result: 42']; + + yield 'float' => [200.0, 'Unwrapped an Ok result: 200.0']; + + yield 'true' => [true, 'Unwrapped an Ok result: true']; + + yield 'false' => [false, 'Unwrapped an Ok result: false']; + + yield 'array' => [[], 'Unwrapped an Ok result.']; + + yield 'class' => [[], 'Unwrapped an Ok result.']; + } + + public function testResultAnd(): void + { + $x = new Ok(2); + $y = new Err('late error'); + self::assertSame($y, $x->and($y)); + + $x = new Err('early error'); + $y = new Ok('foo'); + self::assertSame($x, $x->and($y)); + + $x = new Err('not a 2'); + $y = new Err('late error'); + self::assertSame($x, $x->and($y)); + + $x = new Ok(2); + $y = new Ok('different result type'); + self::assertSame($y, $x->and($y)); + } + + public function testResultAndThen(): void + { + $squareThenToString = static function (int $v): Result { + try { + return new Ok((string) ($v ** 2 / $v)); + } catch (\Throwable $e) { + return new Err($e->getMessage()); + } + }; + + self::assertSame('2', (new Ok(2))->andThen($squareThenToString)->unwrap()); + self::assertSame('Division by zero', (new Ok(0))->andThen($squareThenToString)->unwrapErr()); + self::assertSame('NaN', (new Err('NaN'))->andThen($squareThenToString)->unwrapErr()); + } + + public function testResultOr(): void + { + $x = new Ok(2); + $y = new Err('late error'); + self::assertSame($x, $x->or($y)); + + $x = new Err('early error'); + $y = new Ok('foo'); + self::assertSame($y, $x->or($y)); + + $x = new Err('not a 2'); + $y = new Err('late error'); + self::assertSame($y, $x->or($y)); + + $x = new Ok(2); + $y = new Ok(100); + self::assertSame($x, $x->or($y)); + } + + public function testResultOrElse(): void + { + $sq = static fn(int $x): Result => new Ok($x * $x); + $err = static fn(int $x): Result => new Err($x); + + self::assertTrue((new Ok(2))->orElse($sq)->orElse($sq)->isOk()); + self::assertTrue((new Ok(2))->orElse($err)->orElse($sq)->isOk()); + self::assertTrue((new Err(3))->orElse($sq)->orElse($err)->isOk()); + self::assertTrue((new Err(3))->orElse($err)->orElse($err)->isErr()); + } +} From 740a5f17a9e705db9111079177345deee84d66ff Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 1 Jan 2025 03:18:35 +0800 Subject: [PATCH 7/8] Add ResultTypeInferenceTest --- src/Nexus/Result/Err.php | 19 +++++-- src/Nexus/Result/Ok.php | 15 ++++-- tests/Result/ResultTypeInferenceTest.php | 42 +++++++++++++++ tests/Result/data/result.php | 69 ++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 tests/Result/ResultTypeInferenceTest.php create mode 100644 tests/Result/data/result.php diff --git a/src/Nexus/Result/Err.php b/src/Nexus/Result/Err.php index d4f510c..6a35c6a 100644 --- a/src/Nexus/Result/Err.php +++ b/src/Nexus/Result/Err.php @@ -65,7 +65,14 @@ public function mapOrElse(\Closure $default, \Closure $predicate): mixed return $default($this->err); } - public function mapErr(\Closure $predicate): Result + /** + * @template F + * + * @param (\Closure(E): F) $predicate + * + * @return self + */ + public function mapErr(\Closure $predicate): self { return new self($predicate($this->err)); } @@ -133,10 +140,11 @@ public function andThen(\Closure $op): self /** * @template T * @template F + * @template R of Result * - * @param Result $res + * @param R $res * - * @return Result + * @return R */ public function or(Result $res): Result { @@ -146,10 +154,11 @@ public function or(Result $res): Result /** * @template T * @template F + * @template R of Result * - * @param (\Closure(E): Result) $op + * @param (\Closure(E): R) $op * - * @return Result + * @return R */ public function orElse(\Closure $op): Result { diff --git a/src/Nexus/Result/Ok.php b/src/Nexus/Result/Ok.php index 7bb0481..cacd6f7 100644 --- a/src/Nexus/Result/Ok.php +++ b/src/Nexus/Result/Ok.php @@ -69,7 +69,10 @@ public function mapOrElse(\Closure $default, \Closure $predicate): mixed return $predicate($this->value); } - public function mapErr(\Closure $predicate): Result + /** + * @return self + */ + public function mapErr(\Closure $predicate): self { return $this; } @@ -113,10 +116,11 @@ public function unwrapErr(): never /** * @template U * @template E + * @template R of Result * - * @param Result $res + * @param R $res * - * @return Result + * @return R */ public function and(Result $res): Result { @@ -126,10 +130,11 @@ public function and(Result $res): Result /** * @template U * @template E + * @template R of Result * - * @param (\Closure(T): Result) $op + * @param (\Closure(T): R) $op * - * @return Result + * @return R */ public function andThen(\Closure $op): Result { diff --git a/tests/Result/ResultTypeInferenceTest.php b/tests/Result/ResultTypeInferenceTest.php new file mode 100644 index 0000000..1dbb7a4 --- /dev/null +++ b/tests/Result/ResultTypeInferenceTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Nexus\Tests\Result; + +use PHPStan\Testing\TypeInferenceTestCase; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversNothing] +#[Group('static-analysis')] +final class ResultTypeInferenceTest extends TypeInferenceTestCase +{ + #[DataProvider('provideFileAssertsCases')] + public function testFileAsserts(string $assertType, string $file, mixed ...$args): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** + * @return iterable> + */ + public static function provideFileAssertsCases(): iterable + { + // @phpstan-ignore generator.valueType + yield from self::gatherAssertTypesFromDirectory(__DIR__.'/data'); + } +} diff --git a/tests/Result/data/result.php b/tests/Result/data/result.php new file mode 100644 index 0000000..ce71100 --- /dev/null +++ b/tests/Result/data/result.php @@ -0,0 +1,69 @@ + (int) $v * 2; + +assertType('Nexus\Result\Ok', $ok->map($predicate)); +assertType('Nexus\Result\Err', $err->map($predicate)); + +function bar(Result $result): void +{ + if ($result->isOk()) { + assertType('Nexus\Result\Ok', $result); + assertType('mixed', $result->unwrap()); + assertType('never', $result->unwrapErr()); + } else { + assertType('Nexus\Result\Err', $result); + assertType('never', $result->unwrap()); + assertType('mixed', $result->unwrapErr()); + } +} + +assertType('int', $ok->mapOr(42, $predicate)); +assertType('int', $err->mapOr(42, $predicate)); +assertType('int', $ok->mapOrElse(static fn(string $x): int => \strlen($x), $predicate)); +assertType('int', $err->mapOrElse(static fn(string $x): int => \strlen($x), $predicate)); +assertType('Nexus\Result\Ok', $ok->mapErr(static fn(int $x): string => (string) $x)); +assertType('Nexus\Result\Err', $err->mapErr(static fn(int $x): string => \sprintf('err: %d', $x))); + +assertType('int', $ok->unwrap()); +assertType('never', $err->unwrap()); +assertType('never', $ok->unwrapErr()); +assertType('string', $err->unwrapErr()); + +$x = new Ok(2); +$y = new Err('late error'); +assertType('Nexus\Result\Err', $x->and($y)); +assertType('Nexus\Result\Ok', $x->or($y)); + +$x = new Err('early error'); +$y = new Ok('foo'); +assertType('Nexus\Result\Err', $x->and($y)); +assertType('Nexus\Result\Ok', $x->or($y)); + +$x = new Err('not a 2'); +$y = new Err('late error'); +assertType('Nexus\Result\Err', $x->and($y)); +assertType('Nexus\Result\Err', $x->or($y)); + +$x = new Ok(2); +$y = new Ok('different result type'); +assertType('Nexus\Result\Ok', $x->and($y)); +assertType('Nexus\Result\Ok', $x->or($y)); + +$predicate = static fn(int $x): Ok => new Ok((float) $x); +assertType('Nexus\Result\Ok', (new Ok(2))->andThen($predicate)); +assertType('Nexus\Result\Ok', (new Ok(2))->orElse($predicate)); +assertType('Nexus\Result\Err', (new Err(500))->andThen($predicate)); +assertType('Nexus\Result\Ok', (new Err(500))->orElse($predicate)); From b5ad64c4d3146a9c8cb1c2c57f401d2d60310cfa Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 1 Jan 2025 21:20:29 +0800 Subject: [PATCH 8/8] Update README.md --- src/Nexus/Result/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/Nexus/Result/README.md b/src/Nexus/Result/README.md index 030c3bb..230a524 100644 --- a/src/Nexus/Result/README.md +++ b/src/Nexus/Result/README.md @@ -10,6 +10,31 @@ Result is a type that represents either success (`Ok`) or failure (`Err`). ## Getting Started +```php +orElse(fn(string $err): Result => new Ok(sprintf('error: %s', $err))) + ->unwrap(); + +``` + ## License Nexus Result is licensed under the [MIT License][1].