Add bind() to option and result

This commit is contained in:
Daniel J. Summers 2024-07-28 22:50:59 -04:00
parent efb3a4461e
commit 57af645d87
5 changed files with 89 additions and 8 deletions

View File

@ -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<T>` represents a variable that may or may not have a value. `Result<TOK, TError>` represents the result of an action; the "ok" and "error" states both provide a value. 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<T>` represents a variable that may or may not have a value. `Result<TOK, TError>` represents the result of an action; the "ok" and "error" states both provide a value.
| | `Option<T>`<br>Replaces `null` checks | `Result<TOK, TError>`<br>Replaces exception-based error handling | | | `Option<T>`<br>Replaces `null` checks | `Result<TOK, TError>`<br>Replaces exception-based error handling |
|----------------------------------------------------|--------------------------------------------------------------------------------|------------------------------------------------------------------| |----------------------------------------------------|--------------------------------------------------------------------------------|-----------------------------------------------------------------------------|
| **Creating** | `::Some(T)` for Some<br>`::None()` for None<br>`::of($value)` _None if `null`_ | `::OK(TOK)` for OK<br>`::Error(TError)` for Error | | **Creating** | `::Some(T)` for Some<br>`::None()` for None<br>`::of($value)` _None if `null`_ | `::OK(TOK)` for OK<br>`::Error(TError)` for Error |
| **Querying** | `->isSome()`<br>`->isNone()` | `->isOK()`<br>`->isError()` | | **Querying** | `->isSome(): bool`<br>`->isNone(): bool` | `->isOK(): bool`<br>`->isError(): bool` |
| **Reading**<br>_throws if called on missing value_ | `->get()` | `->getOK()`<br>`->getError()` | | **Reading**<br>_throws if called on missing value_ | `->get(): T` | `->getOK(): TOK`<br>`->getError(): TError` |
| **Transforming**<br>_still `Option` or `Result`_ | `->map(callable(T): U)` | `->map(callable(TOK): U)`<br>`->mapError(callable(TError): U)` | | **Transforming**<br>_still `Option` or `Result`_ | `->map(callable(T): U): U` | `->map(callable(TOK): U): U`<br>`->mapError(callable(TError): U): U` |
| **Iterating** | `->iter(callable(T): void)` | `->iter(callable(TOK): void)` | | **Iterating** | `->iter(callable(T): void): void` | `->iter(callable(TOK): void): void` |
| **Inspecting**<br>_returns the original instance_ | `->tap(callable(Option<T>): void)` | `->tap(callable(Result<TOK, TError>): void)` | | **Inspecting**<br>_returns the original instance_ | `->tap(callable(Option<T>): void): void` | `->tap(callable(Result<TOK, TError>): void): void` |
| **Continued Processing** | `->bind(callable(T): Option<TBound>): Option<TBound>` | `->bind(callable(TOK): Result<TBoundOK, TError>): Result<TBoundOK, TError>` |
In addition to this, `Option<T>` provides: In addition to this, `Option<T>` provides:
- `->getOrDefault(T)` will return the Some value if it exists or the given default if the option is None. - `->getOrDefault(T)` will return the Some value if it exists or the given default if the option is None.

View File

@ -93,6 +93,21 @@ readonly class Option
return $this->value ?? $f(); 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<TBound> $f The function that will receive the Some value; can return a different type
* @return Option<TBound> 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 * Map this optional value to another value
* *

View File

@ -87,6 +87,22 @@ readonly class Result
return $this->errorValue->isSome(); 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<TBoundOK, TError> $f The function that will receive the OK value; can return a different type
* @return Result<TBoundOK, TError> 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 * Map an `OK` result to another, leaving an `Error` result unmodified
* *

View File

@ -85,6 +85,30 @@ class OptionTest extends TestCase
$this->assertEquals('passed', $value, 'The value should have been obtained from the option'); $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')] #[TestDox('Map succeeds with None')]
public function testMapSucceedsWithNone(): void public function testMapSucceedsWithNone(): void
{ {

View File

@ -74,6 +74,31 @@ class ResultTest extends TestCase
$this->assertFalse($result->isError(), 'The check for "Error" should have returned false'); $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')] #[TestDox('Map succeeds for OK result')]
public function testMapSucceedsForOKResult(): void public function testMapSucceedsForOKResult(): void
{ {