Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`** - Represent optional values without null
- **`Result<T, E>`** - Handle errors without exceptions
- **`Lazy<T>`** - Defer computation until needed
- **`Writer<W, T>`** - Carry a value alongside an accumulated log

## Installation

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
},
"files": [
"src/Result/functions.php",
"src/Option/functions.php"
"src/Option/functions.php",
"src/Writer/functions.php"
]
},
"autoload-dev": {
Expand Down
1 change: 0 additions & 1 deletion src/Option/None.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use RuntimeException;
use Superscript\Monads\Result\Result;

use Throwable;

use function Superscript\Monads\Result\Err;
Expand Down
2 changes: 1 addition & 1 deletion src/Option/Some.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
}
1 change: 1 addition & 0 deletions src/Result/Err.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Superscript\Monads\Option\Option;
use Superscript\Monads\Option\Some;
use Throwable;

use function Superscript\Monads\Option\Some;

/**
Expand Down
4 changes: 2 additions & 2 deletions src/Result/Ok.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
}
}
175 changes: 175 additions & 0 deletions src/Writer/Writer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<?php

declare(strict_types=1);

namespace Superscript\Monads\Writer;

use Closure;

/**
* A Writer monad that carries a value alongside an accumulated log.
*
* The log is combined using a provided combiner function, allowing
* flexible log types (arrays, strings, or any monoid-like type).
*
* @template W The log type
* @template-covariant T The value type
*/
final readonly class Writer
{
/**
* @param T $value
* @param W $log
* @param Closure(W, W): W $combiner
*/
private function __construct(
private mixed $value,
private mixed $log,
private Closure $combiner,
) {}

/**
* Create a Writer with a value, initial log, and combiner function.
*
* @template WNew
* @template TNew
*
* @param TNew $value
* @param WNew $log
* @param Closure(WNew, WNew): WNew $combiner
* @return self<WNew, TNew>
*/
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<W, U>
*/
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<W, U> $f
* @return self<W, U>
*/
public function andThen(callable $f): self
{
/** @var self<W, U> $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<W, T>
*/
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<W, T>
*/
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<W, T>
*/
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<W, T>
*/
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<W, U>
*/
public function listen(callable $f): self
{
return new self($f($this->value, $this->log), $this->log, $this->combiner);
}
}
23 changes: 23 additions & 0 deletions src/Writer/functions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Superscript\Monads\Writer;

/**
* Create a Writer with an array-based log.
*
* The log entries are combined using array spread (array_merge-like behavior).
*
* @template T
*
* @param T $value
* @param list<mixed> $log
* @return Writer<list<mixed>, T>
*/
function Writer(mixed $value, array $log = []): Writer
{
$combiner = /** @param list<mixed> $a @param list<mixed> $b @return list<mixed> */ fn(array $a, array $b): array => array_values([...$a, ...$b]);

return Writer::of($value, $log, $combiner);
}
2 changes: 1 addition & 1 deletion tests/Lazy/LazyTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
3 changes: 1 addition & 2 deletions tests/Option/OptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)],
]);
2 changes: 1 addition & 1 deletion tests/Option/OptionTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/Option/types.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,4 @@
assertType(Option::class . '<list<int>>', Option::collect($items));

/** @var Option<Result<int, Throwable>> $x */
assertType(Result::class . '<'.Option::class.'<int>, '.Throwable::class.'>', $x->transpose());
assertType(Result::class . '<' . Option::class . '<int>, ' . Throwable::class . '>', $x->transpose());
Loading