diff --git a/README.md b/README.md index afc8a3f..7fdca88 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,15 @@ This project contains PHP utility classes whose functionality is inspired by the This early-stage library currently provides two classes, both of which are designed to wrap values and indicate the state of the action that produced them. `Option` represents a variable that may or may not have a value. `Result` represents the result of an action; the "ok" and "error" states both provide a value. -| | `Option`
Replaces `null` checks | `Result`
Replaces exception-based error handling | -|----------------------------------------------------|--------------------------------------------------------------------------------|------------------------------------------------------------------| -| **Creating** | `::Some(T)` for Some
`::None()` for None
`::of($value)` _None if `null`_ | `::OK(TOK)` for OK
`::Error(TError)` for Error | -| **Querying** | `->isSome()`
`->isNone()` | `->isOK()`
`->isError()` | -| **Reading**
_throws if called on missing value_ | `->get()` | `->getOK()`
`->getError()` | -| **Transforming**
_still `Option` or `Result`_ | `->map(callable(T): U)` | `->map(callable(TOK): U)`
`->mapError(callable(TError): U)` | -| **Iterating** | `->iter(callable(T): void)` | `->iter(callable(TOK): void)` | -| **Inspecting**
_returns the original instance_ | `->tap(callable(Option): void)` | `->tap(callable(Result): void)` | +| | `Option`
Replaces `null` checks | `Result`
Replaces exception-based error handling | +|----------------------------------------------------|--------------------------------------------------------------------------------|-----------------------------------------------------------------------------| +| **Creating** | `::Some(T)` for Some
`::None()` for None
`::of($value)` _None if `null`_ | `::OK(TOK)` for OK
`::Error(TError)` for Error | +| **Querying** | `->isSome(): bool`
`->isNone(): bool` | `->isOK(): bool`
`->isError(): bool` | +| **Reading**
_throws if called on missing value_ | `->get(): T` | `->getOK(): TOK`
`->getError(): TError` | +| **Transforming**
_still `Option` or `Result`_ | `->map(callable(T): U): U` | `->map(callable(TOK): U): U`
`->mapError(callable(TError): U): U` | +| **Iterating** | `->iter(callable(T): void): void` | `->iter(callable(TOK): void): void` | +| **Inspecting**
_returns the original instance_ | `->tap(callable(Option): void): void` | `->tap(callable(Result): void): void` | +| **Continued Processing** | `->bind(callable(T): Option): Option` | `->bind(callable(TOK): Result): Result` | In addition to this, `Option` provides: - `->getOrDefault(T)` will return the Some value if it exists or the given default if the option is None. diff --git a/src/Option.php b/src/Option.php index 094fd7d..8177c1e 100644 --- a/src/Option.php +++ b/src/Option.php @@ -93,6 +93,21 @@ readonly class Option return $this->value ?? $f(); } + /** + * Bind a function to this option (railway processing) + * + * If this option is Some, the function will be called with the option's value. If this option is None, it will be + * immediately returned. + * + * @template TBound The type returned by Some in the bound function + * @param callable(T): Option $f The function that will receive the Some value; can return a different type + * @return Option The updated option if the starting value was Some, None otherwise + */ + public function bind(callable $f): Option + { + return $this->isNone() ? $this : $f($this->get()); + } + /** * Map this optional value to another value * diff --git a/src/Result.php b/src/Result.php index b9dab26..06fd644 100644 --- a/src/Result.php +++ b/src/Result.php @@ -87,6 +87,22 @@ readonly class Result return $this->errorValue->isSome(); } + /** + * Bind a function to this result (railway processing) + * + * If this result is OK, the function will be called with the OK value of the result. If this result is Error, it + * will be immediately returned. This allows for a sequence of functions to proceed on the happy path (OK all the + * way), or be shunted off to the exit ramp once an error occurs. + * + * @template TBoundOK The type returned by OK in the bound function + * @param callable(TOK): Result $f The function that will receive the OK value; can return a different type + * @return Result The updated result if the function was successful, an error otherwise + */ + public function bind(callable $f): Result + { + return $this->isError() ? $this : $f($this->getOK()); + } + /** * Map an `OK` result to another, leaving an `Error` result unmodified * diff --git a/tests/OptionTest.php b/tests/OptionTest.php index 1999360..f32d6f4 100644 --- a/tests/OptionTest.php +++ b/tests/OptionTest.php @@ -85,6 +85,30 @@ class OptionTest extends TestCase $this->assertEquals('passed', $value, 'The value should have been obtained from the option'); } + #[TestDox('Bind succeeds with None')] + public function testBindSucceedsWithNone(): void + { + $original = Option::None(); + $bound = $original->bind(fn($it) => Option::Some('value')); + $this->assertTrue($bound->isNone(), 'The option should have been None'); + $this->assertSame($original, $bound, 'The same None instance should have been returned'); + } + + #[TestDox('Bind succeeds with Some and Some')] + public function testBindSucceedsWithSomeAndSome(): void + { + $bound = Option::Some('hello')->bind(fn($it) => Option::Some('goodbye')); + $this->assertTrue($bound->isSome(), 'The option should have been Some'); + $this->assertEquals('goodbye', $bound->get(), 'The bound function was not called'); + } + + #[TestDox('Bind succeeds with Some and None')] + public function testBindSucceedsWithSomeAndNone(): void + { + $bound = Option::Some('greetings')->bind(fn($it) => Option::None()); + $this->assertTrue($bound->isNone(), 'The option should have been None'); + } + #[TestDox('Map succeeds with None')] public function testMapSucceedsWithNone(): void { diff --git a/tests/ResultTest.php b/tests/ResultTest.php index 732578d..6baeefb 100644 --- a/tests/ResultTest.php +++ b/tests/ResultTest.php @@ -74,6 +74,31 @@ class ResultTest extends TestCase $this->assertFalse($result->isError(), 'The check for "Error" should have returned false'); } + #[TestDox('Bind succeeds for OK with OK')] + public function testBindSucceedsForOKWithOK(): void + { + $result = Result::OK('one')->bind(fn($it) => Result::OK("$it two")); + $this->assertTrue($result->isOK(), 'The result should have been OK'); + $this->assertEquals('one two', $result->getOK(), 'The bound function was not called'); + } + + #[TestDox('Bind succeeds for OK with Error')] + public function testBindSucceedsForOKWithError(): void + { + $result = Result::OK('three')->bind(fn($it) => Result::Error('back to two')); + $this->assertTrue($result->isError(), 'The result should have been Error'); + $this->assertEquals('back to two', $result->getError(), 'The bound function was not called'); + } + + #[TestDox('Bind succeeds for Error')] + public function testBindSucceedsForError(): void + { + $original = Result::Error('oops'); + $result = $original->bind(fn($it) => Result::OK('never mind - it worked!')); + $this->assertTrue($result->isError(), 'The result should have been Error'); + $this->assertSame($original, $result, 'The same Error result should have been returned'); + } + #[TestDox('Map succeeds for OK result')] public function testMapSucceedsForOKResult(): void {