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.
static function err(E $value = null): Result<T, E>
Returns a result with an error value.
function isOk(): bool
Returns true if the result is ok.
function isErr(): bool
Returns true if the result is err.
function equals(Result<T, E> $other): bool
Returns true if this result equals another result.
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 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 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 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 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 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 get(): T
Returns the ok value.
function getOr(T $value): T
Returns the ok value or the provided value on err.
function getOrNull(): ?T
Returns the ok value or null on err.
function getOrElse(callable(E): T $fn): T
Returns the ok value or a value returned by the provided callback on err.
function getErr(): E
Returns the error value.
function asOk(): Option<T>
Returns Option::Some(T)
where T
is the ok value.
function asErr(): Option<E>
Returns Option::Some(E)
where E
is the error value.
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:
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.
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.
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:
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:
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:
Now, going back to the domain code:
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 User
version in the UserService
:
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.
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:
The response:
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