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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [Unreleased]

### Added

- `Innmind\Immutable\Attempt::guard()`
- `Innmind\Immutable\Attempt::xrecover()`

## 5.19.0 - 2025-09-03

### Added
Expand Down
28 changes: 28 additions & 0 deletions docs/structures/attempt.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ $attempt = Attempt::result(2 - $reduction)

If `#!php $reduction` is `#!php 2` then `#!php $attempt` will contain a `DivisionByZeroError` otherwise for any other value it will contain a fraction of `#!php 42`.

## `->guard()`

This behaves like [`->flatMap()`](#-flatmap) except any error contained in the attempt returned by the callable won't be recovered when calling [`->xrecover()`](#-xrecover).

## `->match()`

This extracts the result value but also forces you to deal with any potential error.
Expand Down Expand Up @@ -146,6 +150,30 @@ $attempt = Attempt::of(static fn() => 1/0)

Here `#!php $attempt` is `#!php 42` because the first `Attempt` raised a `DivisionByZeroError`.

## `->xrecover()`

This behaves like [`->recover()`](#-recover) except when conjointly used with [`->guard()`](#-guard). Guarded errors can't be recovered.

An example of this problem is an HTTP router with 2 routes. One tries to handle a `POST` request, then do some logging, the other tries to handle a `GET` request. It would look something like this:

```php
$response = handlePost($request)
->flatMap(static fn($response) => log($response))
->recover(static fn() => handleGet($request));
```

The problem here is that if the request is indeed a `POST` we handle it then log the response. But if the logging fails then we try to handle it as a `GET` request. In this case we handle the request twice, which isn't good.

The correct approach is:

```php
$response = handlePost($request)
->guard(static fn($response) => log($response))
->xrecover(static fn() => handleGet($request));
```

This way if the logging fails it will return this failure and not call `handleGet()`.

## `->maybe()`

This converts an `Attempt` to a `Maybe`.
Expand Down
108 changes: 108 additions & 0 deletions proofs/attempt.php
Original file line number Diff line number Diff line change
Expand Up @@ -568,4 +568,112 @@ static function($assert, $result1, $result2, $error1, $error2) {
);
},
);

yield proof(
'Attempt::guard()',
given(
Set::integers()->above(1),
Set::integers()->below(-1),
),
static function($assert, $positive, $negative) {
$fail = new Exception;
$assert->same(
$positive,
Attempt::result($positive)
->guard(static fn() => Attempt::result($positive))
->recover(static fn() => Attempt::result($negative))
->match(
static fn($value) => $value,
static fn() => null,
),
);

$assert->same(
$negative,
Attempt::result($positive)
->guard(static fn() => Attempt::error($fail))
->recover(static fn() => Attempt::result($negative))
->match(
static fn($value) => $value,
static fn() => null,
),
);

$assert->same(
$fail,
Attempt::result($positive)
->guard(static fn() => Attempt::error($fail))
->xrecover(static fn() => Attempt::result($negative))
->match(
static fn($value) => $value,
static fn($e) => $e,
),
);

$assert->same(
$negative,
Attempt::result($positive)
->flatMap(static fn() => Attempt::error($fail))
->xrecover(static fn() => Attempt::result($negative))
->match(
static fn($value) => $value,
static fn($e) => $e,
),
);
},
);

yield proof(
'Attempt::defer()->guard()',
given(
Set::integers()->above(1),
Set::integers()->below(-1),
),
static function($assert, $positive, $negative) {
$fail = new Exception;
$assert->same(
$positive,
Attempt::defer(static fn() => Attempt::result($positive))
->guard(static fn() => Attempt::result($positive))
->recover(static fn() => Attempt::result($negative))
->match(
static fn($value) => $value,
static fn() => null,
),
);

$assert->same(
$negative,
Attempt::defer(static fn() => Attempt::result($positive))
->guard(static fn() => Attempt::error($fail))
->recover(static fn() => Attempt::result($negative))
->match(
static fn($value) => $value,
static fn() => null,
),
);

$assert->same(
$fail,
Attempt::defer(static fn() => Attempt::result($positive))
->guard(static fn() => Attempt::error($fail))
->xrecover(static fn() => Attempt::result($negative))
->match(
static fn($value) => $value,
static fn($e) => $e,
),
);

$assert->same(
$negative,
Attempt::defer(static fn() => Attempt::result($positive))
->flatMap(static fn() => Attempt::error($fail))
->xrecover(static fn() => Attempt::result($negative))
->match(
static fn($value) => $value,
static fn($e) => $e,
),
);
},
);
};
54 changes: 50 additions & 4 deletions src/Attempt.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,26 @@ public function map(callable $map): self
#[\NoDiscard]
public function flatMap(callable $map): self
{
return $this->implementation->flatMap($map);
return new self($this->implementation->flatMap(
$map,
static fn(self $self) => $self->implementation,
));
}

/**
* @template U
*
* @param callable(T): self<U> $map
*
* @return self<U>
*/
#[\NoDiscard]
public function guard(callable $map): self
{
return new self($this->implementation->guard(
$map,
static fn(self $self) => $self->implementation,
));
}

/**
Expand Down Expand Up @@ -163,7 +182,28 @@ public function mapError(callable $map): self
#[\NoDiscard]
public function recover(callable $recover): self
{
return $this->implementation->recover($recover);
return new self($this->implementation->recover(
$recover,
static fn(self $self) => $self->implementation,
));
}

/**
* This prevents guarded errors from being recovered.
*
* @template U
*
* @param callable(\Throwable): self<U> $recover
*
* @return self<T|U>
*/
#[\NoDiscard]
public function xrecover(callable $recover): self
{
return new self($this->implementation->xrecover(
$recover,
static fn(self $self) => $self->implementation,
));
}

/**
Expand Down Expand Up @@ -192,7 +232,9 @@ public function either(): Either
#[\NoDiscard]
public function memoize(): self
{
return $this->implementation->memoize();
return new self($this->implementation->memoize(
static fn(self $self) => $self->implementation,
));
}

/**
Expand All @@ -206,6 +248,10 @@ public function memoize(): self
#[\NoDiscard]
public function eitherWay(callable $result, callable $error): self
{
return $this->implementation->eitherWay($result, $error);
return new self($this->implementation->eitherWay(
$result,
$error,
static fn(self $self) => $self->implementation,
));
}
}
58 changes: 44 additions & 14 deletions src/Attempt/Defer.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,29 @@ public function map(callable $map): self
}

#[\Override]
public function flatMap(callable $map): Attempt
{
public function flatMap(
callable $map,
callable $exfiltrate,
): self {
$captured = $this->capture();

return new self(static fn() => self::detonate($captured)->flatMap($map));
}

#[\Override]
public function guard(
callable $map,
callable $exfiltrate,
): self {
$captured = $this->capture();

return Attempt::defer(static fn() => self::detonate($captured)->flatMap($map));
return new self(static fn() => self::detonate($captured)->guard($map));
}

#[\Override]
public function guardError(): self
{
return $this;
}

#[\Override]
Expand All @@ -61,11 +79,23 @@ public function mapError(callable $map): self
}

#[\Override]
public function recover(callable $recover): Attempt
{
public function recover(
callable $recover,
callable $exfiltrate,
): self {
$captured = $this->capture();

return new self(static fn() => self::detonate($captured)->recover($recover));
}

#[\Override]
public function xrecover(
callable $recover,
callable $exfiltrate,
): self {
$captured = $this->capture();

return Attempt::defer(static fn() => self::detonate($captured)->recover($recover));
return new self(static fn() => self::detonate($captured)->xrecover($recover));
}

#[\Override]
Expand All @@ -84,21 +114,21 @@ public function either(): Either
return Either::defer(static fn() => self::detonate($captured)->either());
}

/**
* @return Attempt<R1>
*/
#[\Override]
public function memoize(): Attempt
public function memoize(callable $exfiltrate): Implementation
{
return $this->unwrap();
return $exfiltrate($this->unwrap());
}

#[\Override]
public function eitherWay(callable $result, callable $error): Attempt
{
public function eitherWay(
callable $result,
callable $error,
callable $exfiltrate,
): self {
$captured = $this->capture();

return Attempt::defer(
return new self(
static fn() => self::detonate($captured)->eitherWay($result, $error),
);
}
Expand Down
Loading