diff --git a/README.md b/README.md index 371a24c..ae9a271 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,11 @@ A collection of useful monads for PHP 8.3+. Inspired by Rust's powerful type sys - ๐Ÿ”’ **Type-safe** - Full PHPStan level 9 support with generics - ๐Ÿงช **Well-tested** - Comprehensive test suite - ๐Ÿ“ฆ **Zero dependencies** - Lightweight and focused -- ๐ŸŽฏ **Three core monads**: +- ๐ŸŽฏ **Four core monads**: - **`Option`** - Represent optional values without null - **`Result`** - Handle errors without exceptions - **`Lazy`** - Defer computation until needed + - **`Writer`** - Carry a value alongside an accumulated log ## Installation @@ -161,6 +162,55 @@ if ($needUsers) { } ``` +### Writer Monad + +The `Writer` monad lets you carry a value alongside an accumulated log through a chain of computations. This is useful for logging, auditing, or building up auxiliary data without side effects. + +```php +use Superscript\Monads\Writer\Writer; +use function Superscript\Monads\Writer\Writer; + +// Create a Writer with an array-based log +$writer = Writer(42, ['initial value']); + +// Access value and log +$writer->value(); // 42 +$writer->log(); // ['initial value'] +$writer->run(); // [42, ['initial value']] + +// Transform the value (log unchanged) +Writer(21)->map(fn($x) => $x * 2); // Writer(42, []) + +// Chain computations that produce their own logs +Writer(10, ['start']) + ->andThen(fn($x) => Writer($x * 2, ['doubled'])) + ->andThen(fn($x) => Writer($x + 1, ['incremented'])); +// value: 21, log: ['start', 'doubled', 'incremented'] + +// Append to the log without changing the value +Writer(42)->tell(['something happened']); +// value: 42, log: ['something happened'] + +// Use a custom log type with Writer::of() +$writer = Writer::of('hello', '', fn(string $a, string $b): string => $a . $b) + ->tell(' world') + ->map(fn($v) => strtoupper($v)); +// value: 'HELLO', log: ' world' +``` + +#### Key Writer Methods + +- `value()` - Get the contained value +- `log()` - Get the accumulated log +- `run()` - Get both as a `[$value, $log]` tuple +- `map(callable $f)` - Transform the value, leaving the log unchanged +- `andThen(callable $f)` - Chain a computation that returns a Writer, combining logs (flatMap) +- `tell($entry)` - Append to the log without changing the value +- `mapLog(callable $f)` - Transform the log +- `inspect(callable $f)` - Execute a side effect with the value +- `listen(callable $f)` - Access both value and log to produce a new value +- `reset($log)` - Reset the log to a given value + ### Collection Operations Both `Option` and `Result` support collecting arrays of values: @@ -215,6 +265,29 @@ $error = divide(10, 0) ->unwrapOr(0); // 0 ``` +#### Computation with Logging + +```php +use function Superscript\Monads\Writer\Writer; + +function addTax(float $price): Writer { + $taxed = $price * 1.2; + return Writer($taxed, [sprintf('Tax: %.2f -> %.2f', $price, $taxed)]); +} + +function applyDiscount(float $price): Writer { + $discounted = $price * 0.9; + return Writer($discounted, [sprintf('Discount: %.2f -> %.2f', $price, $discounted)]); +} + +$result = Writer(100.0, ['Starting price: 100.00']) + ->andThen(fn($p) => addTax($p)) + ->andThen(fn($p) => applyDiscount($p)); + +$result->value(); // 108.0 +$result->log(); // ['Starting price: 100.00', 'Tax: 100.00 -> 120.00', 'Discount: 120.00 -> 108.00'] +``` + #### Pipeline Processing ```php diff --git a/composer.json b/composer.json index 675482b..0552aee 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ }, "files": [ "src/Result/functions.php", - "src/Option/functions.php" + "src/Option/functions.php", + "src/Writer/functions.php" ] }, "autoload-dev": { diff --git a/src/Option/None.php b/src/Option/None.php index c351f6c..376457e 100644 --- a/src/Option/None.php +++ b/src/Option/None.php @@ -6,7 +6,6 @@ use RuntimeException; use Superscript\Monads\Result\Result; - use Throwable; use function Superscript\Monads\Result\Err; diff --git a/src/Option/Some.php b/src/Option/Some.php index 45e4c48..91ad48b 100644 --- a/src/Option/Some.php +++ b/src/Option/Some.php @@ -132,6 +132,6 @@ public function transpose(): Result throw new InvalidArgumentException('Cannot transpose a Some value that is not a Result'); } - return $this->value->andThen(fn ($value) => Ok(new self($value))); + return $this->value->andThen(fn($value) => Ok(new self($value))); } } diff --git a/src/Result/Err.php b/src/Result/Err.php index 27e3bae..2c377a7 100644 --- a/src/Result/Err.php +++ b/src/Result/Err.php @@ -9,6 +9,7 @@ use Superscript\Monads\Option\Option; use Superscript\Monads\Option\Some; use Throwable; + use function Superscript\Monads\Option\Some; /** diff --git a/src/Result/Ok.php b/src/Result/Ok.php index 7462bf7..25f4332 100644 --- a/src/Result/Ok.php +++ b/src/Result/Ok.php @@ -37,7 +37,7 @@ public function andThen(callable $other): Result public function err(): Option { - return new None; + return new None(); } public function expect(string|Throwable $message): mixed @@ -150,6 +150,6 @@ public function transpose(): Option throw new InvalidArgumentException('Cannot transpose an Ok value that is not an Option'); } - return $this->value->map(fn ($value) => new self($value)); + return $this->value->map(fn($value) => new self($value)); } } diff --git a/src/Writer/Writer.php b/src/Writer/Writer.php new file mode 100644 index 0000000..154af2c --- /dev/null +++ b/src/Writer/Writer.php @@ -0,0 +1,175 @@ + + */ + public static function of(mixed $value, mixed $log, Closure $combiner): self + { + return new self($value, $log, $combiner); + } + + /** + * Returns the contained value. + * + * @return T + */ + public function value(): mixed + { + return $this->value; + } + + /** + * Returns the accumulated log. + * + * @return W + */ + public function log(): mixed + { + return $this->log; + } + + /** + * Returns both the value and the log as a tuple. + * + * @return array{T, W} + */ + public function run(): array + { + return [$this->value, $this->log]; + } + + /** + * Transforms the value by applying a function, leaving the log unchanged. + * + * @template U + * + * @param callable(T): U $f + * @return self + */ + public function map(callable $f): self + { + return new self($f($this->value), $this->log, $this->combiner); + } + + /** + * Chains a computation that returns a Writer, combining the logs. + * + * Some languages call this operation flatmap or bind. + * + * @template U + * + * @param callable(T): self $f + * @return self + */ + public function andThen(callable $f): self + { + /** @var self $result */ + $result = $f($this->value); + + return new self( + $result->value, + ($this->combiner)($this->log, $result->log), + $this->combiner, + ); + } + + /** + * Appends an entry to the log without changing the value. + * + * @param W $entry + * @return self + */ + public function tell(mixed $entry): self + { + return new self( + $this->value, + ($this->combiner)($this->log, $entry), + $this->combiner, + ); + } + + /** + * Transforms the log by applying a function, leaving the value unchanged. + * + * @param callable(W): W $f + * @return self + */ + public function mapLog(callable $f): self + { + return new self($this->value, $f($this->log), $this->combiner); + } + + /** + * Calls the provided closure with the contained value (for side effects). + * + * @param callable(T): void $f + * @return self + */ + public function inspect(callable $f): self + { + $f($this->value); + + return $this; + } + + /** + * Resets the log to the given value, leaving the value unchanged. + * + * @param W $log + * @return self + */ + public function reset(mixed $log): self + { + return new self($this->value, $log, $this->combiner); + } + + /** + * Accesses both the value and the log, returning a new Writer with the + * callback's return value as the new value, and the log unchanged. + * + * @template U + * + * @param callable(T, W): U $f + * @return self + */ + public function listen(callable $f): self + { + return new self($f($this->value, $this->log), $this->log, $this->combiner); + } +} diff --git a/src/Writer/functions.php b/src/Writer/functions.php new file mode 100644 index 0000000..fdc091c --- /dev/null +++ b/src/Writer/functions.php @@ -0,0 +1,23 @@ + $log + * @return Writer, T> + */ +function Writer(mixed $value, array $log = []): Writer +{ + $combiner = /** @param list $a @param list $b @return list */ fn(array $a, array $b): array => array_values([...$a, ...$b]); + + return Writer::of($value, $log, $combiner); +} diff --git a/tests/Lazy/LazyTypeTest.php b/tests/Lazy/LazyTypeTest.php index 9c8e342..f54070a 100644 --- a/tests/Lazy/LazyTypeTest.php +++ b/tests/Lazy/LazyTypeTest.php @@ -22,7 +22,7 @@ public static function providesTypeAssertions(): array public function testFileAsserts( string $assertType, string $file, - mixed ...$args + mixed ...$args, ): void { $this->assertFileAsserts($assertType, $file, ...$args); } diff --git a/tests/Option/OptionTest.php b/tests/Option/OptionTest.php index 6abbd08..3cefd41 100644 --- a/tests/Option/OptionTest.php +++ b/tests/Option/OptionTest.php @@ -4,7 +4,6 @@ use Superscript\Monads\Option\CannotUnwrapNone; use Superscript\Monads\Option\Option; - use Superscript\Monads\Result\Result; use function Superscript\Monads\Option\None; @@ -187,7 +186,7 @@ ]); test('transpose with non-result', function (Option $option) { - expect(fn () => $option->transpose())->toThrow(new InvalidArgumentException('Cannot transpose a Some value that is not a Result')); + expect(fn() => $option->transpose())->toThrow(new InvalidArgumentException('Cannot transpose a Some value that is not a Result')); })->with([ [Some(2), Some(2)], ]); diff --git a/tests/Option/OptionTypeTest.php b/tests/Option/OptionTypeTest.php index 3fa069b..7c2585c 100644 --- a/tests/Option/OptionTypeTest.php +++ b/tests/Option/OptionTypeTest.php @@ -22,7 +22,7 @@ public static function providesTypeAssertions(): array public function testFileAsserts( string $assertType, string $file, - mixed ...$args + mixed ...$args, ): void { $this->assertFileAsserts($assertType, $file, ...$args); } diff --git a/tests/Option/types.php b/tests/Option/types.php index 170c5b3..5782676 100644 --- a/tests/Option/types.php +++ b/tests/Option/types.php @@ -66,4 +66,4 @@ assertType(Option::class . '>', Option::collect($items)); /** @var Option> $x */ -assertType(Result::class . '<'.Option::class.', '.Throwable::class.'>', $x->transpose()); \ No newline at end of file +assertType(Result::class . '<' . Option::class . ', ' . Throwable::class . '>', $x->transpose()); diff --git a/tests/Result/ResultTest.php b/tests/Result/ResultTest.php index 93b0ce0..2e17837 100644 --- a/tests/Result/ResultTest.php +++ b/tests/Result/ResultTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use Superscript\Monads\Option\Option; use Superscript\Monads\Result\CannotUnwrapErr; use Superscript\Monads\Result\Err; use Superscript\Monads\Result\Ok; @@ -192,21 +191,21 @@ expect($result->unwrapOr($other))->toEqual($expected); })->with([ [Ok(9), 2, 9], - [Err('err'), 2, 2] + [Err('err'), 2, 2], ]); test('unwrap or else', function (Result $result, callable $op, mixed $expected) { expect($result->unwrapOrElse($op))->toEqual($expected); })->with([ [Ok(2), strlen(...), 2], - [Err('foo'), strlen(...), 3] + [Err('foo'), strlen(...), 3], ]); test('unwrap either', function (Result $result, mixed $expected) { expect($result->unwrapEither())->toEqual($expected); })->with([ [Ok(42), 42], - [Err('foo'), 'foo'] + [Err('foo'), 'foo'], ]); test('into ok', function (Ok $ok, mixed $expected) { @@ -225,7 +224,7 @@ expect(Result::collect($items))->toEqual($expected); })->with([ [[Ok(1), Ok(2)], Ok([1, 2])], - [[Err('error')], Err('error')] + [[Err('error')], Err('error')], ]); test('attempt', function () { diff --git a/tests/Result/ResultTypeTest.php b/tests/Result/ResultTypeTest.php index af12ad4..ca0a7df 100644 --- a/tests/Result/ResultTypeTest.php +++ b/tests/Result/ResultTypeTest.php @@ -22,7 +22,7 @@ public static function providesTypeAssertions(): array public function testFileAsserts( string $assertType, string $file, - mixed ...$args + mixed ...$args, ): void { $this->assertFileAsserts($assertType, $file, ...$args); } diff --git a/tests/Result/Testing/AssertionsTest.php b/tests/Result/Testing/AssertionsTest.php index 8840f1f..3aee4ca 100644 --- a/tests/Result/Testing/AssertionsTest.php +++ b/tests/Result/Testing/AssertionsTest.php @@ -16,15 +16,15 @@ class ResultAssertionsTestCase extends TestCase test('assertErr passes with Err result', function () { $errResult = Err('error message'); - + ResultAssertionsTestCase::assertErr($errResult); - + expect(true)->toBeTrue(); // If we get here, the assertion passed }); test('assertErr fails with Ok result', function () { $okResult = Ok('success value'); - + expect(function () { ResultAssertionsTestCase::assertErr($okResult); })->toThrow(ExpectationFailedException::class); @@ -38,15 +38,15 @@ class ResultAssertionsTestCase extends TestCase test('assertOk passes with Ok result', function () { $okResult = Ok('success value'); - + ResultAssertionsTestCase::assertOk($okResult); - + expect(true)->toBeTrue(); // If we get here, the assertion passed }); test('assertOk fails with Err result', function () { $errResult = Err('error message'); - + expect(function () { ResultAssertionsTestCase::assertOk($errResult); })->toThrow(ExpectationFailedException::class); @@ -60,7 +60,7 @@ class ResultAssertionsTestCase extends TestCase test('assertErr with custom message', function () { $okResult = Ok('success value'); - + try { ResultAssertionsTestCase::assertErr($okResult, 'Custom error message'); expect(false)->toBeTrue(); // Should not reach here @@ -71,7 +71,7 @@ class ResultAssertionsTestCase extends TestCase test('assertOk with custom message', function () { $errResult = Err('error message'); - + try { ResultAssertionsTestCase::assertOk($errResult, 'Custom error message'); expect(false)->toBeTrue(); // Should not reach here @@ -82,12 +82,12 @@ class ResultAssertionsTestCase extends TestCase test('isErr constraint returns IsErr instance', function () { $constraint = ResultAssertionsTestCase::isErr(); - + expect($constraint)->toBeInstanceOf(\Superscript\Monads\Result\Testing\IsErr::class); }); test('isOk constraint returns IsOk instance', function () { $constraint = ResultAssertionsTestCase::isOk(); - + expect($constraint)->toBeInstanceOf(\Superscript\Monads\Result\Testing\IsOk::class); -}); \ No newline at end of file +}); diff --git a/tests/Result/types.php b/tests/Result/types.php index 77737f4..51fca45 100644 --- a/tests/Result/types.php +++ b/tests/Result/types.php @@ -53,4 +53,4 @@ assertType('int', $x->unwrapOrElse(fn() => 2)); /** @var Result, Throwable> $x */ -assertType(Option::class . '<'.Result::class.'>', $x->transpose()); +assertType(Option::class . '<' . Result::class . '>', $x->transpose()); diff --git a/tests/Writer/WriterTest.php b/tests/Writer/WriterTest.php new file mode 100644 index 0000000..2efc5eb --- /dev/null +++ b/tests/Writer/WriterTest.php @@ -0,0 +1,121 @@ +value())->toBe(42); + expect(Writer('hello')->value())->toBe('hello'); +}); + +test('log', function () { + expect(Writer(42)->log())->toBe([]); + expect(Writer(42, ['created'])->log())->toBe(['created']); +}); + +test('run', function () { + expect(Writer(42, ['created'])->run())->toBe([42, ['created']]); +}); + +test('map', function () { + $writer = Writer(10, ['initial']) + ->map(fn($x) => $x * 2); + + expect($writer->value())->toBe(20); + expect($writer->log())->toBe(['initial']); +}); + +test('and then', function () { + $writer = Writer(10, ['start']) + ->andThen(fn($x) => Writer($x * 2, ['doubled'])) + ->andThen(fn($x) => Writer($x + 1, ['incremented'])); + + expect($writer->value())->toBe(21); + expect($writer->log())->toBe(['start', 'doubled', 'incremented']); +}); + +test('tell', function () { + $writer = Writer(42, ['initial']) + ->tell(['extra entry']); + + expect($writer->value())->toBe(42); + expect($writer->log())->toBe(['initial', 'extra entry']); +}); + +test('map log', function () { + $writer = Writer(42, ['a', 'b', 'c']) + ->mapLog(fn($log) => array_map('strtoupper', $log)); + + expect($writer->value())->toBe(42); + expect($writer->log())->toBe(['A', 'B', 'C']); +}); + +test('inspect', function () { + $inspected = null; + + $writer = Writer(42, ['log']) + ->inspect(function ($value) use (&$inspected) { + $inspected = $value; + }); + + expect($inspected)->toBe(42); + expect($writer->value())->toBe(42); + expect($writer->log())->toBe(['log']); +}); + +test('reset', function () { + $writer = Writer(42, ['a', 'b', 'c']) + ->reset([]); + + expect($writer->value())->toBe(42); + expect($writer->log())->toBe([]); +}); + +test('listen', function () { + $writer = Writer(42, ['a', 'b']) + ->listen(fn($value, $log) => [$value, count($log)]); + + expect($writer->value())->toBe([42, 2]); + expect($writer->log())->toBe(['a', 'b']); +}); + +test('chaining preserves immutability', function () { + $original = Writer(10, ['start']); + $mapped = $original->map(fn($x) => $x * 2); + + expect($original->value())->toBe(10); + expect($original->log())->toBe(['start']); + expect($mapped->value())->toBe(20); + expect($mapped->log())->toBe(['start']); +}); + +test('of with custom combiner', function () { + $writer = Writer::of('hello', '', fn(string $a, string $b): string => $a . $b) + ->tell(' world') + ->andThen(fn($v) => Writer::of(strtoupper($v), '!', fn(string $a, string $b): string => $a . $b)); + + expect($writer->value())->toBe('HELLO'); + expect($writer->log())->toBe(' world!'); +}); + +test('pipeline with logging', function () { + $addTax = fn($price) => Writer( + $price * 1.2, + [sprintf('Added tax: %.2f -> %.2f', $price, $price * 1.2)], + ); + + $applyDiscount = fn($price) => Writer( + $price * 0.9, + [sprintf('Applied 10%% discount: %.2f -> %.2f', $price, $price * 0.9)], + ); + + $writer = Writer(100.0, ['Starting price: 100.00']) + ->andThen($addTax) + ->andThen($applyDiscount); + + expect($writer->value())->toBe(108.0); + expect($writer->log())->toHaveCount(3); +}); diff --git a/tests/Writer/types.php b/tests/Writer/types.php new file mode 100644 index 0000000..5ac02b7 --- /dev/null +++ b/tests/Writer/types.php @@ -0,0 +1,33 @@ +, int>', $writer); +assertType('int', $writer->value()); +assertType('list', $writer->log()); +assertType('array{int, list}', $writer->run()); + +$mapped = Writer(42)->map(fn(int $x): string => (string) $x); +assertType('Superscript\Monads\Writer\Writer, string>', $mapped); +assertType('string', $mapped->value()); + +$chained = Writer(42)->andThen(fn(int $x) => Writer($x * 2, ['doubled'])); +assertType('Superscript\Monads\Writer\Writer, int>', $chained); + +$told = Writer(42)->tell(['entry']); +assertType('Superscript\Monads\Writer\Writer, int>', $told); + +$inspected = Writer(42)->inspect(fn(int $x) => null); +assertType('Superscript\Monads\Writer\Writer, int>', $inspected); + +$listened = Writer(42)->listen(fn(int $value, $log) => 'result'); +assertType('Superscript\Monads\Writer\Writer, string>', $listened); + +$custom = Writer::of('hello', '', fn(string $a, string $b): string => $a . $b); +assertType('Superscript\Monads\Writer\Writer', $custom); +assertType('string', $custom->value()); +assertType('string', $custom->log());