diff --git a/src/IO/IO.php b/src/IO/IO.php index 1296949..a223175 100644 --- a/src/IO/IO.php +++ b/src/IO/IO.php @@ -118,6 +118,57 @@ public function handleError(callable $handler): IO }); } + /** + * Recovers from an error with another effect, which then runs in place of this one. + * + * @template B + * @param callable(Throwable): IO $handler + * @return IO + */ + public function handleErrorWith(callable $handler): IO + { + return new IO(function () use ($handler) { + try { + return ($this->unsafeRun)(); + } catch (Throwable $e) { + return $handler($e)->unsafeRun(); + } + }); + } + + /** + * Recovers from errors of one class with another effect; every other error keeps propagating. + * + * @template B + * @param class-string $exceptionClass + * @param callable(Throwable): IO $handler + * @return IO + */ + public function recover(string $exceptionClass, callable $handler): IO + { + return $this->handleErrorWith(fn (Throwable $e) => $e instanceof $exceptionClass ? $handler($e) : new IO(fn () => throw $e)); + } + + /** + * Runs this effect and then the given ones in order, combining every result with $f. + * + * @template B + * @param list> $fbs + * @param callable(mixed ...$results): B $f + * @return IO + */ + public function mapN(array $fbs, callable $f): IO + { + return new IO(function () use ($fbs, $f) { + $results = [($this->unsafeRun)()]; + foreach ($fbs as $fb) { + $results[] = $fb->unsafeRun(); + } + + return $f(...$results); + }); + } + /** * Attempts to execute the effect, capturing any error in a Validation. * diff --git a/tests/Unit/IO/RecoveryTest.php b/tests/Unit/IO/RecoveryTest.php new file mode 100644 index 0000000..bca7aaf --- /dev/null +++ b/tests/Unit/IO/RecoveryTest.php @@ -0,0 +1,54 @@ + throw new RuntimeException('boom')) + ->handleErrorWith(fn (RuntimeException $e) => io(fn () => 'recovered from ' . $e->getMessage())); + + $this->assertSame('recovered from boom', $recovered->unsafeRun()); + $this->assertSame(42, io(fn () => 42)->handleErrorWith(fn () => io(fn () => 0))->unsafeRun()); + } + + #[Test] + public function recover_handles_only_the_named_exception_class(): void + { + $recover = fn ($e) => io(fn () => 'handled'); + + $this->assertSame('handled', io(fn () => throw new InvalidArgumentException('bad'))->recover(InvalidArgumentException::class, $recover)->unsafeRun()); + + $this->expectException(RuntimeException::class); + io(fn () => throw new RuntimeException('other'))->recover(InvalidArgumentException::class, $recover)->unsafeRun(); + } + + #[Test] + public function mapN_runs_the_effects_in_order_and_combines_their_results(): void + { + $log = []; + $step = function (string $name) use (&$log) { + return io(function () use (&$log, $name) { + $log[] = $name; + + return strtoupper($name); + }); + }; + + $combined = $step('a')->mapN([$step('b'), $step('c')], fn (string $a, string $b, string $c) => $a . $b . $c); + + $this->assertSame([], $log); + $this->assertSame('ABC', $combined->unsafeRun()); + $this->assertSame(['a', 'b', 'c'], $log); + } +}