Represents a value of one of two possible types (a disjoint union).
An instance of Either
is either an instance of Left
or Right
.
A common use of Either
is as an alternative to Option
for dealing with possible missing values.
In this usage, None
is replaced with a Left
which can contain useful information.
Right
takes the place of Some
.
This example should be familiar to you by previous example for Option.
But instead null
we use exceptions.
<?php
declare(strict_types=1);
function inverse(int $number): float
{
if (0 === $number) {
throw new \Error('Cannot divide by zero');
}
return 1 / $number;
}
/**
* @param list<int> $numbers
*/
function head(array $numbers): int
{
if ([] === $numbers) {
throw new \Error('Empty array');
}
return $numbers[0];
}
/**
* @param list<int> $numbers
*/
function takeFirstInverseNumber(array $numbers): string
{
try {
$result = inverse(head($numbers));
return "Result is: {$result}";
} catch (Error $e) {
return "Error is: {$e}";
}
}
var_dump(takeFirstInverseNumber([10, 20, 30])); // Result is: 0.1
var_dump(takeFirstInverseNumber([0, 20, 30])); // Error is: Cannot divide by zero
var_dump(takeFirstInverseNumber([])); // Error is: Empty array
The example above has problems:
- There are no
throws
docblocks. We should look at function implementations to understand what should and shouldn't be caught. - We can forget to catch an exception. Yes, static analysis can force it, but we don't have
throws
docblocks. - Static analysis for catching exceptions doesn't work well and has the big hole: https://psalm.dev/r/bfb26e0ddf
- Ugly nesting.
Functional approach:
<?php
declare(strict_types=1);
use Fp4\PHP\Either as E;
use function Fp4\PHP\Combinator\pipe;
final class AppError
{
public function __construct(
public readonly string $message,
) {
}
}
/**
* @return E\Either<AppError, float>
*/
function inverse(int $number): E\Either
{
return 0 === $number
? E\left(new AppError('Cannot divide by zero'))
: E\right(1 / $number);
}
/**
* @param list<int> $numbers
* @return E\Either<AppError, int>
*/
function head(array $numbers): E\Either
{
return [] === $numbers
? E\left(new AppError('Empty array'))
: E\right($numbers[0]);
}
/**
* @param list<int> $numbers
*/
function takeFirstInverseNumber(array $numbers): string
{
return pipe(
head($numbers),
E\flatMap(inverse(...)),
E\map(fn($result) => "Result is: {$result}"),
E\mapLeft(fn($error) => "Error is: {$error->message}"),
E\unwrap(...),
);
}
var_dump(takeFirstInverseNumber([10, 20, 30])); // Result is: 0.1
var_dump(takeFirstInverseNumber([0, 20, 30])); // Error is: Cannot divide by zero
var_dump(takeFirstInverseNumber([])); // Error is: Empty array
Convention dictates that Left
is used for failure and Right
is used for success.
- We see all possible outcomes at type-level.
- When working with
Either
, it becomes more difficult to forget about error handling. If it is necessary, then it will need to be done explicitly! - No nesting.
Either
same as Option
has bind
and bindable
operations:
<?php
declare(strict_types=1);
use Fp4\PHP\Either as E;
use function Fp4\PHP\Combinator\pipe;
final class User
{
public function __construct(
public readonly int $userId,
public readonly string $login,
) {
}
}
final class UserNotFound {}
final class Project
{
public function __construct(
public readonly int $projectId,
public readonly string $name,
) {
}
}
final class ProjectNotFound {}
final class ProjectInvite
{
public function __construct(
public readonly User $user,
public readonly Project $project,
) {
}
}
/**
* @return E\Either<UserNotFound, User>
*/
function findUserById(int $userId): E\Either
{
return 42 === $userId
? E\right(new User(42, 'example'))
: E\left(new UserNotFound());
}
/**
* @return E\Either<ProjectNotFound, Project>
*/
function findProjectById(int $projectId): E\Either
{
return 42 === $projectId
? E\right(new Project(42, 'example'))
: E\left(new ProjectNotFound());
}
/**
* @return E\Either<UserNotFound|ProjectNotFound, ProjectInvite>
*/
function makeProjectInviteFlatMap(int $userId, int $projectId): E\Either
{
return pipe(
findUserById($userId),
E\flatMap(fn(User $user) => pipe(
findProjectById($projectId),
E\map(fn(Project $project) => new ProjectInvite($user, $project)),
)),
);
}
/**
* @return E\Either<UserNotFound|ProjectNotFound, ProjectInvite>
*/
function makeProjectInviteBindable(int $userId, int $projectId): E\Either
{
return pipe(
E\bindable(),
E\bind(
user: fn() => findUserById($userId),
project: fn() => findProjectById($projectId),
),
E\map(fn($i) => new ProjectInvite($i->user, $i->project)),
);
}
See to type E\Either<UserNotFound|ProjectNotFound, ProjectInvite>
.
Psalm can infer all possible errors that can happen.
With your old exceptional api, you can interact via tryCatch
operation:
<?php
declare(strict_types=1);
use Fp4\PHP\Either as E;
use function Fp4\PHP\Combinator\pipe;
final class YourErrorStructure
{
public function __construct(
public readonly int $code,
public readonly string $message,
) {}
}
function mayThrows(): int
{
throw new \RuntimeException('Error');
}
/**
* @return E\Either<YourErrorStructure, int>
*/
function interop(): E\Either
{
return pipe(
E\tryCatch(fn() => mayThrows()),
E\map(fn(int $a) => $a + 1)
E\mapLeft(fn(Throwable $e) => new YourErrorStructure(
code: $e->getCode(),
message: $e->getMessage(),
)),
);
}