diff --git a/src/Result.php b/src/Result.php new file mode 100644 index 0000000..9da7e21 --- /dev/null +++ b/src/Result.php @@ -0,0 +1,168 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace BitBadger\InspiredByFSharp; + +use InvalidArgumentException; + +/** + * The result of an operation; one of two possible outcomes. + * + * While exceptions are still possible in F# (and PHP), a more robust model is returning an object that can represent + * either success or failure. The `Result` type exposes an `OK` state for the successful result of an action and an + * `Error` state for the unsuccessful result of that action. Neither result value can be `null`; both should be + * meaningful for the action, and either eventuality should be handled by calling code. + * + * The net result _(no pun intended)_ is code that fails gracefully, and calling code that is aware that not every call + * is successful. + * + * @template TOK The type of the OK result + * @template TError The type of the error result + */ +readonly class Result +{ + /** @var Option The OK value for this result */ + private Option $okValue; + + /** @var Option The error value for this result */ + private Option $errorValue; + + /** + * Constructor + * + * @param TOK|null $okValue The OK value for this result + * @param TError|null $errorValue The error value for this result + */ + private function __construct(mixed $okValue = null, mixed $errorValue = null) + { + $this->okValue = Option::of($okValue); + $this->errorValue = Option::of($errorValue); + } + + /** + * Get the value for an `OK` result + * + * @return TOK The OK value for this result + * @throws InvalidArgumentException If the result is an `Error` result + */ + public function getOK(): mixed + { + return $this->okValue->get(); + } + + /** + * Get the value for an `Error` result + * + * @return TError The error value for this result + * @throws InvalidArgumentException If the result is an `OK` result + */ + public function getError(): mixed + { + return $this->errorValue->get(); + } + + /** + * Create an `OK` result + * + * @param TOK $value The OK value for this result + * @return Result The `OK` result for the value specified + */ + public static function OK(mixed $value): self + { + if (is_null($value)) { + throw new InvalidArgumentException('Cannot use null as an OK value'); + } + return new self(okValue: $value); + } + + /** + * Create an `Error` result + * + * @param TError $value The error value for this result + * @return Result The `Error` result for the value specified + */ + public static function Error(mixed $value): self + { + if (is_null($value)) { + throw new InvalidArgumentException('Cannot use null as an Error value'); + } + return new self(errorValue: $value); + } + + /** + * Is the given result `OK`? + * + * @param Result $it The result in question + * @return bool True if the result is `OK`, false if it is `Error` + */ + public static function isOK(Result $it): bool + { + return Option::isSome($it->okValue); + } + + /** + * Is the given result `Error`? + * + * @param Result $it The result in question + * @return bool True if the result is `Error`, false if it is `OK` + */ + public static function isError(Result $it): bool + { + return Option::isSome($it->errorValue); + } + + /** + * Map an `OK` result to another, leaving an `Error` result unmodified + * + * @template U The type of the mapping function + * @param callable(TOK): U $f The mapping function + * @param Result $it The result in question + * @return Result A transformed `OK` instance, or an `Error` instance with the same value + */ + public static function map(callable $f, Result $it): self + { + return self::isOK($it) ? self::OK($f($it->getOK())) : self::Error($it->getError()); + } + + /** + * Map an `Error` result to another, leaving an `OK` result unmodified + * + * @template U The type of the mapping function + * @param callable(TError): U $f The mapping function + * @param Result $it The result in question + * @return Result A transformed `Error` instance, or an `OK` instance with the same value + */ + public static function mapError(callable $f, Result $it): self + { + return self::isError($it) ? self::Error($f($it->getError())) : self::OK($it->getOK()); + } + + /** + * Execute a function on an `OK` value (if it exists) + * + * @param callable(TOK): void $f The function to call + * @param Result $it The result in question + */ + public static function iter(callable $f, Result $it): void + { + if (self::isOK($it)) { + $f($it->getOK()); + } + } + + /** + * Transform a `Result`'s `OK` value to an `Option` + * + * @param Result $it The result in question + * @return Option A `Some` option with the OK value if `OK`, `None` if `Error` + */ + public static function toOption(Result $it): Option + { + return Result::isOK($it) ? Option::Some($it->getOK()) : Option::None(); + } +} diff --git a/tests/ResultTest.php b/tests/ResultTest.php new file mode 100644 index 0000000..115626e --- /dev/null +++ b/tests/ResultTest.php @@ -0,0 +1,200 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Test; + +use BitBadger\InspiredByFSharp\Option; +use BitBadger\InspiredByFSharp\Result; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\TestCase; + +/** + * Unit tests for the Result class + */ +class ResultTest extends TestCase +{ + #[TestDox('GetOK succeeds for OK result')] + public function testGetOKSucceedsForOKResult(): void + { + $result = Result::OK('yay'); + $this->assertEquals('yay', $result->getOK(), 'The OK result should have been returned'); + } + + #[TestDox('GetOK fails for Error result')] + public function testGetOKFailsForErrorResult(): void + { + $this->expectException(InvalidArgumentException::class); + Result::Error('whoops')->getOK(); + } + + #[TestDox('GetError succeeds for Error result')] + public function testGetErrorSucceedsForErrorResult(): void + { + $result = Result::Error('boo'); + $this->assertEquals('boo', $result->getError(), 'The Error result should have been returned'); + } + + #[TestDox('GetError fails for OK result')] + public function testGetErrorFailsForOKResult(): void + { + $this->expectException(InvalidArgumentException::class); + Result::OK('yeah')->getError(); + } + + #[TestDox('OK succeeds for non null result')] + public function testOKSucceedsForNonNullResult(): void + { + $result = Result::OK('something'); + $this->assertTrue(Result::isOK($result), 'The result should have been "OK"'); + $this->assertEquals('something', $result->getOK(), 'The "OK" value was incorrect'); + } + + #[TestDox('OK fails for null result')] + public function testOKFailsForNullResult(): void + { + $this->expectException(InvalidArgumentException::class); + Result::OK(null); + } + + #[TestDox('Error succeeds for non null result')] + public function testErrorSucceedsForNonNullResult(): void + { + $result = Result::Error('sad trombone'); + $this->assertTrue(Result::isError($result), 'The result should have been "Error"'); + $this->assertEquals('sad trombone', $result->getError(), 'The "Error" value was incorrect'); + } + + #[TestDox('Error fails for null result')] + public function testErrorFailsForNullResult(): void + { + $this->expectException(InvalidArgumentException::class); + Result::Error(null); + } + + #[TestDox('IsOK succeeds for OK result')] + public function testIsOKSucceedsForOKResult(): void + { + $result = Result::OK('ok'); + $this->assertTrue(Result::isOK($result), 'The check for "OK" should have returned true'); + } + + #[TestDox('IsOK succeeds for Error result')] + public function testIsOKSucceedsForErrorResult(): void + { + $result = Result::Error('error'); + $this->assertFalse(Result::isOK($result), 'The check for "OK" should have returned false'); + } + + #[TestDox('IsError succeeds for Error result')] + public function testIsErrorSucceedsForErrorResult(): void + { + $result = Result::Error('not ok'); + $this->assertTrue(Result::isError($result), 'The check for "Error" should have returned true'); + } + + #[TestDox('IsError succeeds for OK result')] + public function testIsErrorSucceedsForOKResult(): void + { + $result = Result::OK('fine'); + $this->assertFalse(Result::isError($result), 'The check for "Error" should have returned false'); + } + + #[TestDox('Map succeeds for OK result')] + public function testMapSucceedsForOKResult(): void + { + $ok = Result::OK('yard'); + $mapped = Result::map(fn($it) => strrev($it), $ok); + $this->assertTrue(Result::isOK($mapped), 'The mapped result should be "OK"'); + $this->assertEquals('dray', $mapped->getOK(), 'The mapping function was not called correctly'); + } + + #[TestDox('Map fails for OK result when mapping is null')] + public function testMapFailsForOKResultWhenMappingIsNull(): void + { + $this->expectException(InvalidArgumentException::class); + Result::map(fn($it) => null, Result::OK('not null')); + } + + #[TestDox('Map succeeds for Error result')] + public function testMapSucceedsForErrorResult(): void + { + $tattle = new class { public bool $called = false; }; + $error = Result::Error('nope'); + $mapped = Result::map(function ($ignored) use ($tattle) + { + $tattle->called = true; + return 'hello'; + }, $error); + $this->assertTrue(Result::isError($mapped), 'The mapped result should be "Error"'); + $this->assertFalse($tattle->called, 'The mapping function should not have been called'); + $this->assertNotSame($error, $mapped, 'There should have been a new "Error" instance returned'); + } + + #[TestDox('MapError succeeds for OK result')] + public function testMapErrorSucceedsForOKResult(): void + { + $tattle = new class { public bool $called = false; }; + $ok = Result::OK('sure'); + $mapped = Result::mapError(function ($ignored) use ($tattle) + { + $tattle->called = true; + return 'hello'; + }, $ok); + $this->assertTrue(Result::isOK($mapped), 'The mapped result should be "OK"'); + $this->assertFalse($tattle->called, 'The mapping function should not have been called'); + $this->assertNotSame($ok, $mapped, 'There should have been a new "OK" instance returned'); + } + + #[TestDox('MapError succeeds for Error result')] + public function testMapErrorSucceedsForErrorResult(): void + { + $error = Result::Error('taco'); + $mapped = Result::mapError(fn($it) => str_repeat('*', strlen($it)), $error); + $this->assertTrue(Result::isError($mapped), 'The mapped result should be "Error"'); + $this->assertEquals('****', $mapped->getError(), 'The mapping function was not called correctly'); + } + + #[TestDox('MapError fails for Error result when mapping is null')] + public function testMapErrorFailsForErrorResultWhenMappingIsNull(): void + { + $this->expectException(InvalidArgumentException::class); + Result::mapError(fn($it) => null, Result::Error('pizza')); + } + + #[TestDox('Iter succeeds for OK result')] + public function testIterSucceedsForOKResult(): void + { + $target = new class { public mixed $called = null; }; + Result::iter(function ($it) use ($target) { $target->called = $it; }, Result::OK(77)); + $this->assertEquals(77, $target->called, 'The function should have been called with the "OK" value'); + } + + #[TestDox('Iter succeeds for Error result')] + public function testIterSucceedsForErrorResult(): void + { + $target = new class { public mixed $called = null; }; + Result::iter(function ($ignored) use ($target) { $target->called = 'uh oh'; }, Result::Error('')); + $this->assertNull($target->called, 'The function should not have been called'); + } + + #[TestDox('ToOption succeeds for OK result')] + public function testToOptionSucceedsForOKResult() + { + $value = Result::toOption(Result::OK(99)); + $this->assertTrue(Option::isSome($value), 'An "OK" result should map to a "Some" option'); + $this->assertEquals(99, $value->get(), 'The value is not correct'); + } + + #[TestDox('ToOption succeeds for Error result')] + public function testToOptionSucceedsForErrorResult() + { + $value = Result::toOption(Result::Error('file not found')); + $this->assertTrue(Option::isNone($value), 'An "Error" result should map to a "None" option'); + } +}