From e807c30fccb5cea34e1ba0d3dbc9db53a943d774 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 5 Aug 2026 08:39:47 +0100 Subject: [PATCH 1/2] Raise from an IO when its value is not acceptable --- docs/io.md | 23 +++++++ src/IO/IO.php | 2 + src/Ops/IO/MonadErrorOps.php | 73 ++++++++++++++++++++++ tests/Unit/Ops/IO/MonadErrorOpsTest.php | 82 +++++++++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 src/Ops/IO/MonadErrorOps.php create mode 100644 tests/Unit/Ops/IO/MonadErrorOpsTest.php diff --git a/docs/io.md b/docs/io.md index 6dd1c06..ed62e22 100644 --- a/docs/io.md +++ b/docs/io.md @@ -258,6 +258,29 @@ $getUser = io(fn() => $db->findUser($id)) ->handleError(fn($e) => null); // Return null if not found ``` +### ensure() - Fail When a Value Is Not Acceptable + +`ensure` raises an error when the produced value does not satisfy a predicate. It +stays lazy — the check runs when the IO is run — and the raised error is +recoverable through `attempt()` / `handleError()`. + +```php +$positive = io(fn() => readNumber()) + ->ensure(fn($n) => $n > 0, new \RuntimeException('must be positive')); + +// ensureOr builds the error from the offending value +$positive = io(fn() => readNumber()) + ->ensureOr(fn($n) => $n > 0, fn($n) => new \RuntimeException("not positive: $n")); +``` + +`ensure` is how you express a conditional failure over an IO. A for-comprehension +**guard** — `for { $x <- io if $x > 0 } yield $x` — is deliberately not supported +for IO: a lazy `IO` has no empty value to fall through to, so there is nothing +for a failed guard to become. This mirrors cats-effect, where filtering an IO is +done with `ensure`/`ensureOr` rather than with for-comprehension guard syntax. A +plain `for { $x <- io } yield ...` (no guard) works as usual, desugaring to +`flatMap`/`map`. + ### Combining Error Handling ```php diff --git a/src/IO/IO.php b/src/IO/IO.php index d442574..1296949 100644 --- a/src/IO/IO.php +++ b/src/IO/IO.php @@ -20,6 +20,7 @@ use Phunkie\Effect\Concurrent\FiberExecutionContext; use Phunkie\Effect\Ops\IO\ApplicativeOps; use Phunkie\Effect\Ops\IO\FunctorOps; +use Phunkie\Effect\Ops\IO\MonadErrorOps; use Phunkie\Effect\Ops\IO\MonadOps; use Phunkie\Effect\Ops\IO\ParallelOps; use Phunkie\Types\Kind; @@ -45,6 +46,7 @@ class IO implements Functor, Applicative, Monad, Parallel, Kind use FunctorOps; use ApplicativeOps; use MonadOps; + use MonadErrorOps; use ParallelOps; private $unsafeRun; diff --git a/src/Ops/IO/MonadErrorOps.php b/src/Ops/IO/MonadErrorOps.php new file mode 100644 index 0000000..7289b92 --- /dev/null +++ b/src/Ops/IO/MonadErrorOps.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Phunkie\Effect\Ops\IO; + +use Phunkie\Effect\IO\IO; +use Throwable; + +/** + * MonadError combinators for IO. + * + * A lazy IO has no empty value to filter into, so it deliberately has no + * withFilter — a for-comprehension guard has nothing to fall through to. The + * intent behind a guard is instead written explicitly here, through the error + * channel: ensure raises when a predicate fails, recoverable via attempt(). + * This mirrors cats-effect, where filtering an IO is done with ensure/ensureOr + * rather than with for-comprehension guard syntax. + * + * @template A + */ +trait MonadErrorOps +{ + /** + * Raises $error when the produced value does not satisfy $predicate. + * + * Stays lazy: the check runs when the returned IO is run, and the raised + * error is recoverable through attempt() / handleError(). + * + * @param callable(A):bool $predicate The condition the value must satisfy + * @param Throwable $error The error to raise when it does not + * @return IO + */ + public function ensure(callable $predicate, Throwable $error): IO + { + return new IO(function () use ($predicate, $error) { + $a = ($this->unsafeRun)(); + + if (! $predicate($a)) { + throw $error; + } + + return $a; + }); + } + + /** + * Like ensure, but the error is computed from the offending value. + * + * @param callable(A):bool $predicate The condition the value must satisfy + * @param callable(A):Throwable $error Builds the error from the value that failed + * @return IO + */ + public function ensureOr(callable $predicate, callable $error): IO + { + return new IO(function () use ($predicate, $error) { + $a = ($this->unsafeRun)(); + + if (! $predicate($a)) { + throw $error($a); + } + + return $a; + }); + } +} diff --git a/tests/Unit/Ops/IO/MonadErrorOpsTest.php b/tests/Unit/Ops/IO/MonadErrorOpsTest.php new file mode 100644 index 0000000..f199812 --- /dev/null +++ b/tests/Unit/Ops/IO/MonadErrorOpsTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\Phunkie\Effect\Ops\IO; + +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Phunkie\Effect\IO\IO; +use RuntimeException; + +class MonadErrorOpsTest extends TestCase +{ + #[Test] + public function it_passes_the_value_through_when_the_predicate_holds() + { + $io = new IO(fn () => 42); + + $ensured = $io->ensure(fn ($x) => $x > 0, new RuntimeException("must be positive")); + + $this->assertEquals(42, $ensured->unsafeRun()); + } + + #[Test] + public function it_raises_the_error_when_the_predicate_fails() + { + $io = new IO(fn () => -1); + + $ensured = $io->ensure(fn ($x) => $x > 0, new RuntimeException("must be positive")); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("must be positive"); + $ensured->unsafeRun(); + } + + #[Test] + public function it_does_not_run_the_effect_until_the_ensured_io_is_run() + { + $ran = false; + $io = new IO(function () use (&$ran) { + $ran = true; + + return -1; + }); + + $io->ensure(fn ($x) => $x > 0, new RuntimeException("boom")); + + $this->assertFalse($ran, "ensure must stay lazy and not run the effect"); + } + + #[Test] + public function it_is_recoverable_through_attempt() + { + $io = new IO(fn () => -1); + + $recovered = $io + ->ensure(fn ($x) => $x > 0, new RuntimeException("must be positive")) + ->attempt() + ->map(fn ($validation) => $validation->fold(fn ($e) => $e->getMessage())(fn ($a) => "ok: $a")); + + $this->assertEquals("must be positive", $recovered->unsafeRun()); + } + + #[Test] + public function it_builds_the_error_from_the_offending_value_with_ensure_or() + { + $io = new IO(fn () => -5); + + $ensured = $io->ensureOr(fn ($x) => $x > 0, fn ($x) => new RuntimeException("bad value: $x")); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("bad value: -5"); + $ensured->unsafeRun(); + } +} From a97d48a1be2a0e48e41a3d9adff4ba126fc02bca Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 5 Aug 2026 08:39:55 +0100 Subject: [PATCH 2/2] Match effect's own IO when deconstructing it --- composer.json | 10 +++- macros/pattern-matching.syn | 19 ++++++++ src/PatternMatching/Referenced/io.php | 38 +++++++++++++++ .../Unit/PatternMatching/ReferencedIOTest.php | 46 +++++++++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 macros/pattern-matching.syn create mode 100644 src/PatternMatching/Referenced/io.php create mode 100644 tests/Unit/PatternMatching/ReferencedIOTest.php diff --git a/composer.json b/composer.json index de20c71..a78c7ad 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ ], "require": { "php": "^8.2 || ^8.3 || ^8.4 || ^8.5", - "phunkie/phunkie": "^1.1.0" + "phunkie/phunkie": "^1.2.0" }, "require-dev": { "phpunit/phpunit": "^10.5", @@ -22,12 +22,18 @@ "suggest": { "ext-parallel": "Required for parallel execution using threads. PHP must be compiled with ZTS support." }, + "extra": { + "phunkiec": { + "macros": "macros/" + } + }, "autoload": { "psr-4": { "Phunkie\\Effect\\": "src/" }, "files": [ - "src/Functions/common.php" + "src/Functions/common.php", + "src/PatternMatching/Referenced/io.php" ] }, "autoload-dev": { diff --git a/macros/pattern-matching.syn b/macros/pattern-matching.syn new file mode 100644 index 0000000..42892e1 --- /dev/null +++ b/macros/pattern-matching.syn @@ -0,0 +1,19 @@ +# Pattern matching for effect's types. +# +# Shipped by phunkie/effect and discovered by phunkiec through the +# `extra.phunkiec.macros` entry in this package's composer.json. These rules are +# loaded before phunkiec's bundled rules, so where a name collides — `IO` is both +# phunkie's Cats\IO and effect's own IO — effect's rule wins when effect is +# installed. +# +# The rules are anchored on `$on(`, which only phunkiec's `match { }` desugaring +# ever emits, so an IO constructed anywhere else in the file is left alone. The +# bound part is captured as `$(T_VARIABLE ...)`, so `IO(_)` (wildcard) and a +# by-value `IO(...)` are left untouched. + +# effect IO — binds the wrapped thunk +$(macro) { + $on( IO( $(T_VARIABLE as thunk) ) ) +} >> { + $on(\Phunkie\Effect\PatternMatching\Referenced\IO($(thunk))) +} diff --git a/src/PatternMatching/Referenced/io.php b/src/PatternMatching/Referenced/io.php new file mode 100644 index 0000000..2de54ce --- /dev/null +++ b/src/PatternMatching/Referenced/io.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Phunkie\Effect\PatternMatching\Referenced; + +use Phunkie\Effect\IO\IO as IOType; +use Phunkie\PatternMatching\Referenced\GenericReferenced; + +/** + * Creates a pattern that matches an effect IO and binds the thunk it wraps. + * + * This is effect's own IO pattern, distinct from phunkie's Referenced\IO, which + * targets Phunkie\Cats\IO. It builds on phunkie's GenericReferenced, so it needs + * phunkie 1.2. + * + * Example: + * ```php + * $on = pmatch(new IO(fn () => 42)); + * $result = match (true) { + * $on(IO($thunk)) => $thunk() // $thunk is the wrapped effect, so 42 + * }; + * ``` + * + * @param mixed $thunk Variable that receives the thunk wrapped by the IO + * @return GenericReferenced Pattern matching an effect IO + */ +function IO(&$thunk): GenericReferenced +{ + return new GenericReferenced(IOType::class, $thunk); +} diff --git a/tests/Unit/PatternMatching/ReferencedIOTest.php b/tests/Unit/PatternMatching/ReferencedIOTest.php new file mode 100644 index 0000000..1738203 --- /dev/null +++ b/tests/Unit/PatternMatching/ReferencedIOTest.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\Phunkie\Effect\PatternMatching; + +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Phunkie\Effect\IO\IO; + +use function Phunkie\Effect\PatternMatching\Referenced\IO as IOPattern; + +class ReferencedIOTest extends TestCase +{ + #[Test] + public function it_binds_the_thunk_when_matching_an_io() + { + $on = pmatch(new IO(fn () => 42)); + + $result = match (true) { + $on(IOPattern($thunk)) => $thunk() + }; + + $this->assertEquals(42, $result); + } + + #[Test] + public function it_does_not_match_a_value_that_is_not_an_io() + { + $on = pmatch(42); + + $result = match (true) { + $on(IOPattern($thunk)) => "io", + $on(_) => "not an io" + }; + + $this->assertEquals("not an io", $result); + } +}