Skip to content

Commit 4347266

Browse files
committed
Merge branch 'develop'
* develop: specify next release typo prove deferred attempts behave the same way add Attempt::guard() and ::xrecover() give more flexibility to the attempt implementations
2 parents 8175f0c + b836c6e commit 4347266

9 files changed

Lines changed: 428 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 5.20.0 - 2025-09-06
4+
5+
### Added
6+
7+
- `Innmind\Immutable\Attempt::guard()`
8+
- `Innmind\Immutable\Attempt::xrecover()`
9+
310
## 5.19.0 - 2025-09-03
411

512
### Added

docs/structures/attempt.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ $attempt = Attempt::result(2 - $reduction)
111111

112112
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`.
113113

114+
## `->guard()`
115+
116+
This behaves like [`->flatMap()`](#-flatmap) except any error contained in the attempt returned by the callable won't be recovered when calling [`->xrecover()`](#-xrecover).
117+
114118
## `->match()`
115119

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

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

153+
## `->xrecover()`
154+
155+
This behaves like [`->recover()`](#-recover) except when conjointly used with [`->guard()`](#-guard). Guarded errors can't be recovered.
156+
157+
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:
158+
159+
```php
160+
$response = handlePost($request)
161+
->flatMap(static fn($response) => log($response))
162+
->recover(static fn() => handleGet($request));
163+
```
164+
165+
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.
166+
167+
The correct approach is:
168+
169+
```php
170+
$response = handlePost($request)
171+
->guard(static fn($response) => log($response))
172+
->xrecover(static fn() => handleGet($request));
173+
```
174+
175+
This way if the logging fails it will return this failure and not call `handleGet()`.
176+
149177
## `->maybe()`
150178

151179
This converts an `Attempt` to a `Maybe`.

proofs/attempt.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,4 +568,112 @@ static function($assert, $result1, $result2, $error1, $error2) {
568568
);
569569
},
570570
);
571+
572+
yield proof(
573+
'Attempt::guard()',
574+
given(
575+
Set::integers()->above(1),
576+
Set::integers()->below(-1),
577+
),
578+
static function($assert, $positive, $negative) {
579+
$fail = new Exception;
580+
$assert->same(
581+
$positive,
582+
Attempt::result($positive)
583+
->guard(static fn() => Attempt::result($positive))
584+
->recover(static fn() => Attempt::result($negative))
585+
->match(
586+
static fn($value) => $value,
587+
static fn() => null,
588+
),
589+
);
590+
591+
$assert->same(
592+
$negative,
593+
Attempt::result($positive)
594+
->guard(static fn() => Attempt::error($fail))
595+
->recover(static fn() => Attempt::result($negative))
596+
->match(
597+
static fn($value) => $value,
598+
static fn() => null,
599+
),
600+
);
601+
602+
$assert->same(
603+
$fail,
604+
Attempt::result($positive)
605+
->guard(static fn() => Attempt::error($fail))
606+
->xrecover(static fn() => Attempt::result($negative))
607+
->match(
608+
static fn($value) => $value,
609+
static fn($e) => $e,
610+
),
611+
);
612+
613+
$assert->same(
614+
$negative,
615+
Attempt::result($positive)
616+
->flatMap(static fn() => Attempt::error($fail))
617+
->xrecover(static fn() => Attempt::result($negative))
618+
->match(
619+
static fn($value) => $value,
620+
static fn($e) => $e,
621+
),
622+
);
623+
},
624+
);
625+
626+
yield proof(
627+
'Attempt::defer()->guard()',
628+
given(
629+
Set::integers()->above(1),
630+
Set::integers()->below(-1),
631+
),
632+
static function($assert, $positive, $negative) {
633+
$fail = new Exception;
634+
$assert->same(
635+
$positive,
636+
Attempt::defer(static fn() => Attempt::result($positive))
637+
->guard(static fn() => Attempt::result($positive))
638+
->recover(static fn() => Attempt::result($negative))
639+
->match(
640+
static fn($value) => $value,
641+
static fn() => null,
642+
),
643+
);
644+
645+
$assert->same(
646+
$negative,
647+
Attempt::defer(static fn() => Attempt::result($positive))
648+
->guard(static fn() => Attempt::error($fail))
649+
->recover(static fn() => Attempt::result($negative))
650+
->match(
651+
static fn($value) => $value,
652+
static fn() => null,
653+
),
654+
);
655+
656+
$assert->same(
657+
$fail,
658+
Attempt::defer(static fn() => Attempt::result($positive))
659+
->guard(static fn() => Attempt::error($fail))
660+
->xrecover(static fn() => Attempt::result($negative))
661+
->match(
662+
static fn($value) => $value,
663+
static fn($e) => $e,
664+
),
665+
);
666+
667+
$assert->same(
668+
$negative,
669+
Attempt::defer(static fn() => Attempt::result($positive))
670+
->flatMap(static fn() => Attempt::error($fail))
671+
->xrecover(static fn() => Attempt::result($negative))
672+
->match(
673+
static fn($value) => $value,
674+
static fn($e) => $e,
675+
),
676+
);
677+
},
678+
);
571679
};

src/Attempt.php

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,26 @@ public function map(callable $map): self
108108
#[\NoDiscard]
109109
public function flatMap(callable $map): self
110110
{
111-
return $this->implementation->flatMap($map);
111+
return new self($this->implementation->flatMap(
112+
$map,
113+
static fn(self $self) => $self->implementation,
114+
));
115+
}
116+
117+
/**
118+
* @template U
119+
*
120+
* @param callable(T): self<U> $map
121+
*
122+
* @return self<U>
123+
*/
124+
#[\NoDiscard]
125+
public function guard(callable $map): self
126+
{
127+
return new self($this->implementation->guard(
128+
$map,
129+
static fn(self $self) => $self->implementation,
130+
));
112131
}
113132

114133
/**
@@ -163,7 +182,28 @@ public function mapError(callable $map): self
163182
#[\NoDiscard]
164183
public function recover(callable $recover): self
165184
{
166-
return $this->implementation->recover($recover);
185+
return new self($this->implementation->recover(
186+
$recover,
187+
static fn(self $self) => $self->implementation,
188+
));
189+
}
190+
191+
/**
192+
* This prevents guarded errors from being recovered.
193+
*
194+
* @template U
195+
*
196+
* @param callable(\Throwable): self<U> $recover
197+
*
198+
* @return self<T|U>
199+
*/
200+
#[\NoDiscard]
201+
public function xrecover(callable $recover): self
202+
{
203+
return new self($this->implementation->xrecover(
204+
$recover,
205+
static fn(self $self) => $self->implementation,
206+
));
167207
}
168208

169209
/**
@@ -192,7 +232,9 @@ public function either(): Either
192232
#[\NoDiscard]
193233
public function memoize(): self
194234
{
195-
return $this->implementation->memoize();
235+
return new self($this->implementation->memoize(
236+
static fn(self $self) => $self->implementation,
237+
));
196238
}
197239

198240
/**
@@ -206,6 +248,10 @@ public function memoize(): self
206248
#[\NoDiscard]
207249
public function eitherWay(callable $result, callable $error): self
208250
{
209-
return $this->implementation->eitherWay($result, $error);
251+
return new self($this->implementation->eitherWay(
252+
$result,
253+
$error,
254+
static fn(self $self) => $self->implementation,
255+
));
210256
}
211257
}

src/Attempt/Defer.php

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,29 @@ public function map(callable $map): self
3939
}
4040

4141
#[\Override]
42-
public function flatMap(callable $map): Attempt
43-
{
42+
public function flatMap(
43+
callable $map,
44+
callable $exfiltrate,
45+
): self {
46+
$captured = $this->capture();
47+
48+
return new self(static fn() => self::detonate($captured)->flatMap($map));
49+
}
50+
51+
#[\Override]
52+
public function guard(
53+
callable $map,
54+
callable $exfiltrate,
55+
): self {
4456
$captured = $this->capture();
4557

46-
return Attempt::defer(static fn() => self::detonate($captured)->flatMap($map));
58+
return new self(static fn() => self::detonate($captured)->guard($map));
59+
}
60+
61+
#[\Override]
62+
public function guardError(): self
63+
{
64+
return $this;
4765
}
4866

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

6381
#[\Override]
64-
public function recover(callable $recover): Attempt
65-
{
82+
public function recover(
83+
callable $recover,
84+
callable $exfiltrate,
85+
): self {
86+
$captured = $this->capture();
87+
88+
return new self(static fn() => self::detonate($captured)->recover($recover));
89+
}
90+
91+
#[\Override]
92+
public function xrecover(
93+
callable $recover,
94+
callable $exfiltrate,
95+
): self {
6696
$captured = $this->capture();
6797

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

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

87-
/**
88-
* @return Attempt<R1>
89-
*/
90117
#[\Override]
91-
public function memoize(): Attempt
118+
public function memoize(callable $exfiltrate): Implementation
92119
{
93-
return $this->unwrap();
120+
return $exfiltrate($this->unwrap());
94121
}
95122

96123
#[\Override]
97-
public function eitherWay(callable $result, callable $error): Attempt
98-
{
124+
public function eitherWay(
125+
callable $result,
126+
callable $error,
127+
callable $exfiltrate,
128+
): self {
99129
$captured = $this->capture();
100130

101-
return Attempt::defer(
131+
return new self(
102132
static fn() => self::detonate($captured)->eitherWay($result, $error),
103133
);
104134
}

0 commit comments

Comments
 (0)