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/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/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/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/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/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();
+ }
+}
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);
+ }
+}