Result

Represents either a success (ok) or a failure (err) result.

Overview

Normally, to represent a successful result, you'd just return it directly from a function or a method and to represent an error you'd use exceptions. However, exceptions are not ideal in cases where "an error is expected" and I'll delve deeper into it in the code example.

Instead of throwing exceptions, you can use Result::err() to represent recoverable errors and for successful results Result::ok().

API

static function ok(T $value = null): Result<T, E>

Returns a result with an ok value.

Result::ok(2);
ok(2); // alias

static function err(E $value = null): Result<T, E>

Returns a result with an error value.

Result::err(3);
err(3); // alias

function isOk(): bool

Returns true if the result is ok.

assert(true === ok(2)->isOk());
assert(false === err(2)->isOk());

function isErr(): bool

Returns true if the result is err.

assert(true === err(3)->isErr());
assert(false === ok(3)->isErr());

function equals(Result<T, E> $other): bool

Returns true if this result equals another result.

assert(true === ok(2)->equals(ok(2)));
assert(true === err(3)->equals(err(3)));
assert(false === ok(2)->equals(ok('2')));
assert(false === ok(2)->equals(err(2)));

function andThen<U>(callable(T): U $fn): Result<U, E>

Maps the ok value by using the provided callback and returns a new result, leaving the error value untouched.

function mul(int $value): int { return 2 * $value; }

assert(4 === ok(2)->andThen('mul')->get());
assert(2 === err(2)->andThen('mul')->getErr());

function andThenTo<U>(callable(T): Result<U, E> $fn): Result<U, E>

Maps the ok value by using the provided callback which returns a new result that can be either ok or err.

function mul(int $value): Result { return ok(2 * $value); }

assert(8 === ok(2)->andThenTo('mul')->andThenTo('mul')->get());
assert(4 === ok(2)->andThenTo('mul')->andThenTo('err')->getErr());
assert(2 === ok(2)->andThenTo('err')->andThenTo('mul')->getErr());
assert(2 === err(2)->andThenTo('mul')->andThenTo('mul')->getErr());

function orElse<R>(callable(E): R $fn): Result<T, R>

Maps the error value by using the provided callback and returns a new result, leaving the ok value untouched.

function mul(int $value): int { return 2 * $value; }
 
assert(2 === ok(2)->orElse('mul')->get());
assert(4 === err(2)->orElse('mul')->getErr());

function orElseTo<R>(callable(E): Result<T, R> $fn): Result<T, R>

Maps the err value by using the provided callback which returns a new result that can be either ok or err.

function mul(int $value): Result { return ok(2 * $value); }

assert(2 === ok(2)->orElseTo('mul')->orElseTo('err')->get());
assert(4 === err(2)->orElseTo('mul')->orElseTo('err')->get());
assert(4 === err(2)->orElseTo('err')->orElseTo('mul')->get());
assert(2 === err(2)->orElseTo('err')->orElseTo('err')->getErr());

function mapOr<U>(U $value, callable(T): U $okFn): U

If the result is ok, it maps its value using the provided $okFn callback. Otherwise, returns the untouched result.

function mul(int $value): int { return 3 * $value; }

assert(6 === ok(2)->mapOr(3, 'mul'));
assert(3 === err(6)->mapOr(3, 'mul'));

function mapOrElse<U>(callable(E): U $errFn, callable(T): U $okFn): U

If the result is ok, it maps its value using the provided $okFn callback. Otherwise, if the result is err, it maps its value using the provided $errFn callback.

function mul(int $value): int { return 3 * $value; }
function sum(int $value): int { return 2 + $value; }

assert(6 === ok(2)->mapOrElse('sum', 'mul'));
assert(5 === err(3)->mapOrElse('sum', 'mul'));

function get(): T

Returns the ok value.

assert(2 === ok(2)->get());
err(1)->get(); // throws an exception

function getOr(T $value): T

Returns the ok value or the provided value on err.

assert(2 === ok(2)->getOr(3));
assert(3 === err(1)->getOr(3));

function getOrNull(): ?T

Returns the ok value or null on err.

assert(2 === ok(2)->getOrNull());
assert(null === err(1)->getOrNull());

function getOrElse(callable(E): T $fn): T

Returns the ok value or a value returned by the provided callback on err.

function mul(int $value) { return 3 * $value; }

assert(2 === ok(2)->getOrElse('mul'));
assert(9 === err(3)->getOrElse('mul'));

function getErr(): E

Returns the error value.

assert(3 === err(3)->getErr());
ok(1)->getErr(); // throws an exception

function asOk(): Option<T>

Returns Option::Some(T) where T is the ok value.

assert(2 === ok(2)->asOk()->get());
assert(true === err(3)->asOk()->isNone());

function asErr(): Option<E>

Returns Option::Some(E) where E is the error value.

assert(3 === err(3)->asErr()->get());
assert(true === ok(2)->asErr()->isNone());

Code example

Let's start with some typical code, without using, for now, the Result type. We're going to use it later when refactoring the code.

Prelim

In the domain, you'd write something similar to the following:

class User
{
    private string $username;
    private string $password;
    
    public static function register(string $username, string $encodedPassword): self
    {
        if (3 > strlen($username) || 10 < strlen($username)) {
            throw new \InvalidArgumentException('Username must be between 3 and 10 chars.');
        }
        if (!ctype_alnum($username)) {
            throw new \InvalidArgumentException('Username can consist only of letters and digits.');
        }
        
        return new self($username, $encodedPassword);
    }
}

Of course, this is not the only way of reporting errors from the domain, but I believe it's the most used one.

Now, let's define the UserService that will orchestrate the user registration and delegate it to the domain.

class UserService
{
    public function registerUser(UserRegistrationData $data): User
    {
        $user = $this->userRepository->find($data->username);
        if ($user) {
            throw new \InvalidArgumentException(sprintf('Username "%s" is already taken.', $data->username));
        }
        
        try {
            $user = User::register($data->username, $this->passwordEncoder->encode($data->password));
            $this->userRepository->save($user);
        } catch (\InvalidArgumentException $e) {
            throw new \InvalidArgumentException(sprintf('Invalid user registration data: %s', $e->getMessage(), null, $e);
        }
        
        return $user;
    }
}

I know that some of you'd like to not return anything from the method, but let it pass for this example.

It'll be good to handle exceptions from the domain, so we won't bother clients what exceptions the domain has. Clients just need to know what the UserService returns or throws.

Finally, we need to use the UserService somewhere. Nowadays, it's very popular to publish the application API using REST, so let's do it.

use Jungi\FrameworkExtraBundle\Attribute\RequestBody;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

#[Route('/users')]
class UserResource extends AbstractController
{
    private UserService $userService;
    
    #[Route(methods: ['POST'])]
    public function register(#[RequestBody] UserRegistrationData $data): Response
    {
        try {
            $user = $this->userService->registerUser($data);
            
            return $this->json(['username' => $user->username()], 201);
        } catch (\InvalidArgumentException $e) {
            return $this->json(['message' => $e->getMessage()], 400);
        }
    }
}

With this last part, we're basically done, but there're a few drawbacks to this exception approach.

Let's go back to the domain. If we pass eg. too-long-and-invalid as $username an exception is thrown as intended, but the code execution stops where the exception is and the exception is propagated up until it's caught. Otherwise, PHP panics with the exception and that's something we'd like to avoid.

In our case, we properly handled the exception, so our app won't crash, but we end up returning to the user a 400 response:

{
    "message": "Invalid user registration data: Username must be between 3 and 10 chars."
}

Actually, it's not that bad yet. The user is smart, fixed the username accordingly to the error message, and resend the request. This time the user receives another a 400 response, but with a different error message:

{
    "message": "Invalid user registration data: Username can consist only of letters and digits."
}

Hmm, couldn't our app point it out in the first request? We can see it resembles a little ping-pong game: error, fix, another error, another fix, success (maybe). The exception approach limits us to one error per request. The presented domain validation logic here is quite simple, but in the real world, with more advanced domains it may get more tricky and tedious. For sure, our app needs to be refactored. We'd like for our app to be able to report all possible errors in the first request.

The problem is not new, it's something that's quite known and it's well described by Martin Fowler as the Notification pattern. I recommend reading Replacing Throwing Exceptions with Notification in Validations.

if a failure is expected behavior, then you shouldn't be using exceptions - Martin Fowler

And another important quote:

(...) Exceptions signal something outside the expected bounds of behavior of the code in question. But if you're running some checks on outside input, this is because you expect some messages to fail - and if a failure is expected behavior, then you shouldn't be using exceptions. (...) - Martin Fowler

With that being said, exceptions weren't designed for reporting expected errors. Moreover, even our example shows that exceptions limit us.

Refactor

Now, it's finally time for some refactor using the Result type and the Notification pattern!

To build a notification we're going to use a simple array that will contain Error. We could still use only the error message, but let's also add the error code, so clients can exactly identify an error. For this, we need a new type to hold these data:

class Error
{
    public function __construct(
        private string $message,
        private string $code,
    ) {}
    
    public function message(): string { return $this->message; }
    public function code(): string { return $this->code; }
}

Now, going back to the domain code:

class User
{
    public const EXCEEDED_USERNAME_LENGTH = 'exceeded_username_length';
    public const INVALID_USERNAME = 'invalid_username';
    
    private string $username;
    private string $password;
    
    /** Result<User, Error[]> */
    public static function register(string $username, string $encodedPassword): Result
    {
        $errors = [];
        if (3 > strlen($username) || 10 < strlen($username)) {
            $errors[] = new Error('Username must be between 3 and 10 chars.', self::EXCEEDED_USERNAME_LENGTH);
        }
        if (!ctype_alnum($username)) {
            $errors[] = new Error('Username can consist only of letters and digits.', self::INVALID_USERNAME);
        }
        
        if ($errors) {
            return err($errors);
        }
        
        return ok(new self($username, $encodedPassword));
    }
}

Much better! Now, we control the code execution, and we're able to pass more errors than one. If we type again too-long-and-invalid as $username we'll get two errors, instead of one.

Now, let's properly handle the refactored Userversion in the UserService:

class UserService
{
    public const USERNAME_ALREADY_TAKEN = 'username_already_taken';
    
    /** @return Result<User, Error[]> */
    public function registerUser(UserRegistrationData $data): Result
    {
        $user = $this->userRepository->find($data->username);
        if ($user) {
            return err([ new Error(sprintf('Username "%s" is already taken.', $data->username), self::USERNAME_ALREADY_TAKEN) ]);
        }
        
        return User::register($data->username, $this->passwordEncoder->encode($data->password))
            ->andThen(function (User $user) {
                $this->userRepository->save($user);
                
                return $user;
            });
    }
}

We deliberately stopped the code execution and returned a single error when the user exists, there's no point in further validation in this case.

use Symfony\Component\Routing\Annotation\Route;
use Jungi\FrameworkExtraBundle\Attribute\RequestBody;
use Symfony\Component\HttpFoundation\Response;

#[Route('/users')]
class UserResource extends AbstractController
{
    private UserService $userService;
    
    #[Route(methods: ['POST'])]
    public function register(#[RequestBody] UserRegistrationData $data): Response
    {
        return $this->userService->registerUser($data)
            ->andThen(fn(User $user) => $this->json(['username' => $user->username()], 201))
            ->getOrElse(function(array $errors) {
                $formattedErrors = array_map(fn(Error $error) => array(
                    'message' => $e->message(), 
                    'code' => $e->code()
                ), $errors);
                
                return $this->json($formattedErrors, 400);
            });
    }
}

That's all! Thanks to the Result type and the Notification pattern, everything went smoothly and we got rid of all try-catch blocks! Furthermore, the code is now even more readable than before, and we exactly know what to expect from it. When used the exception approach, we couldn't control the code execution flow, but with the Result we ourselves decide what to do with it, whether to propagate it up or panic, it's totally up to us.

As a final step, let's quickly check what response we get to the following request:

{ "username": "too-long-and-invalid", "password": "123" }

The response:

[
    { "message": "Username must be between 3 and 10 chars.", "code": "exceeded_username_length" },
    { "message": "Username can consist only of letters and digits.", "code": "invalid_username" },
]

I think the goal of the refactor has been achieved, and not only achieved but also exceeded.

I hope this code example showed the whole beauty of the Result type and cleared the confusion about where it can be used.

Last updated