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\Validation::guard()`
- `Innmind\Immutable\Validation::xotherwise()`

## 5.18.0 - 2025-08-08

### Added
Expand Down
28 changes: 28 additions & 0 deletions docs/structures/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ $validation = isEmail('foo@example.com');
$localEmail = $either->flatMap(fn(string $email): Validation => isLocal($email));
```

## `->guard()`

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

## `->match()`

This is the only way to extract the wrapped value.
Expand Down Expand Up @@ -98,6 +102,30 @@ $email = isEmail('invalid value')
->otherwise(fn() => isEmail('foo@example.com'));
```

## `->xotherwise()`

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

An example of this problem is an HTTP router with 2 validations. One tries to validate it's a `POST` request, then validates the request body, the other tries to validate a `GET` request. It would look something like this:

```php
$result = validatePost($request)
->flatMap(static fn() => validateBody($request))
->otherwise(static fn() => validateGet($request));
```

The problem here is that if the request is indeed a `POST` we try to validate the body. But if the latter fails then we try to validate it's a `GET` query. In this case the failure would indicate the request is not a `GET`, which doesn't make sense.

The correct approach is:

```php
$result = validatePost($request)
->guard(static fn() => validateBody($request))
->xotherwise(static fn() => validateGet($request));
```

This way if the body validation fails it will return this failure and not that it's not a `GET`.

## `->mapFailures()`

This is similar to the `->map()` function but will be applied on each failure.
Expand Down
54 changes: 54 additions & 0 deletions proofs/validation.php
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,58 @@ static function($assert, $a, $b) {
);
},
);

yield proof(
'Validation::guard()',
given(
Set::integers()->above(1),
Set::integers()->below(-1),
Set::type(),
),
static function($assert, $positive, $negative, $fail) {
$assert->same(
$positive,
Validation::success($positive)
->guard(static fn() => Validation::success($positive))
->otherwise(static fn() => Validation::success($negative))
->match(
static fn($value) => $value,
static fn() => null,
),
);

$assert->same(
$negative,
Validation::success($positive)
->guard(static fn() => Validation::fail($fail))
->otherwise(static fn() => Validation::success($negative))
->match(
static fn($value) => $value,
static fn() => null,
),
);

$assert->same(
[$fail],
Validation::success($positive)
->guard(static fn() => Validation::fail($fail))
->xotherwise(static fn() => Validation::success($negative))
->match(
static fn($value) => $value,
static fn($failures) => $failures->toList(),
),
);

$assert->same(
$negative,
Validation::success($positive)
->flatMap(static fn() => Validation::fail($fail))
->xotherwise(static fn() => Validation::success($negative))
->match(
static fn($value) => $value,
static fn($failures) => $failures->toList(),
),
);
},
);
};
36 changes: 36 additions & 0 deletions src/Validation.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,23 @@ public function flatMap(callable $map): self
));
}

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

/**
* @template T
*
Expand Down Expand Up @@ -105,6 +122,25 @@ public function otherwise(callable $map): self
return $this->implementation->otherwise($map);
}

/**
* This prevents guarded failures from being recovered.
*
* @template T
* @template V
*
* @param callable(Sequence<F>): self<T, V> $map
*
* @return self<F|T, S|V>
*/
#[\NoDiscard]
public function xotherwise(callable $map): self
{
return $this->implementation->xotherwise(
$map,
static fn(Validation\Implementation $implementation) => new self($implementation),
);
}

/**
* @template A
* @template T
Expand Down
69 changes: 67 additions & 2 deletions src/Validation/Fail.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@
final class Fail implements Implementation
{
/**
* @param Sequence<F> $failures
* @param Sequence<F>|Guard<F> $failures
*/
private function __construct(
private Sequence $failures,
private Sequence|Guard $failures,
) {
}

Expand Down Expand Up @@ -70,6 +70,34 @@ public function flatMap(callable $map, callable $exfiltrate): Implementation
return $this;
}

/**
* @template T
* @template V
*
* @param callable(S): Validation<T, V> $map
* @param pure-callable(Validation<T, V>): Implementation<T, V> $exfiltrate
*
* @return Implementation<F|T, V>
*/
#[\Override]
public function guard(callable $map, callable $exfiltrate): Implementation
{
/** @var Implementation<F|T, V> */
return $this;
}

#[\Override]
public function guardFailures(): self
{
if ($this->failures instanceof Guard) {
return $this;
}

return new self(new Guard(
$this->failures,
));
}

/**
* @template T
*
Expand All @@ -94,6 +122,34 @@ public function mapFailures(callable $map): Implementation
#[\Override]
public function otherwise(callable $map): Validation
{
if ($this->failures instanceof Guard) {
/** @psalm-suppress ImpureFunctionCall */
return $map($this->failures->unwrap());
}

/** @psalm-suppress ImpureFunctionCall */
return $map($this->failures);
}

/**
* @template T
* @template V
*
* @param callable(Sequence<F>): Validation<T, V> $map
* @param callable(Implementation<F|T, S|V>): Validation<F|T, S|V> $wrap
*
* @return Validation<F|T, S|V>
*/
#[\Override]
public function xotherwise(
callable $map,
callable $wrap,
): Validation {
if ($this->failures instanceof Guard) {
/** @psalm-suppress ImpureFunctionCall */
return $wrap($this);
}

/** @psalm-suppress ImpureFunctionCall */
return $map($this->failures);
}
Expand Down Expand Up @@ -133,6 +189,11 @@ public function and(Implementation $other, callable $fold): Implementation
#[\Override]
public function match(callable $success, callable $failure)
{
if ($this->failures instanceof Guard) {
/** @psalm-suppress ImpureFunctionCall */
return $failure($this->failures->unwrap());
}

/** @psalm-suppress ImpureFunctionCall */
return $failure($this->failures);
}
Expand All @@ -152,6 +213,10 @@ public function maybe(): Maybe
#[\Override]
public function either(): Either
{
if ($this->failures instanceof Guard) {
return Either::left($this->failures->unwrap());
}

return Either::left($this->failures);
}
}
58 changes: 58 additions & 0 deletions src/Validation/Guard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php
declare(strict_types = 1);

namespace Innmind\Immutable\Validation;

use Innmind\Immutable\Sequence;

/**
* @internal
* @template T
* @psalm-immutable
*/
final class Guard
{
/**
* @param Sequence<T> $failures
*/
public function __construct(
private Sequence $failures,
) {
}

/**
* @template U
*
* @param callable(T): U $map
*
* @return self<U>
*/
public function map(callable $map): self
{
return new self($this->failures->map($map));
}

/**
* @param Sequence<T>|self<T> $other
*
* @return self<T>
*/
public function append(Sequence|self $other): self
{
if ($other instanceof self) {
$other = $other->failures;
}

return new self(
$this->failures->append($other),
);
}

/**
* @return Sequence<T>
*/
public function unwrap(): Sequence
{
return $this->failures;
}
}
30 changes: 30 additions & 0 deletions src/Validation/Implementation.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ public function map(callable $map): self;
*/
public function flatMap(callable $map, callable $exfiltrate): self;

/**
* @template T
* @template V
*
* @param callable(S): Validation<T, V> $map
* @param pure-callable(Validation<T, V>): self<T, V> $exfiltrate
*
* @return self<F|T, V>
*/
public function guard(callable $map, callable $exfiltrate): self;

/**
* @return self<F, S>
*/
public function guardFailures(): self;

/**
* @template T
*
Expand All @@ -56,6 +72,20 @@ public function mapFailures(callable $map): self;
*/
public function otherwise(callable $map): Validation;

/**
* @template T
* @template V
*
* @param callable(Sequence<F>): Validation<T, V> $map
* @param callable(self<F|T, S|V>): Validation<F|T, S|V> $wrap
*
* @return Validation<F|T, S|V>
*/
public function xotherwise(
callable $map,
callable $wrap,
): Validation;

/**
* @template A
* @template T
Expand Down
Loading