Result Objects - Errors Without Exceptions
In Ruby, errors and failures are typically implemented with exceptions. In some situations, however, exceptions may not be the best choice. This article covers some of the problems with exceptions, and introduces a functional, alternative approach to error handling.
Sometimes Exceptions Suck
Pop quiz: assuming there are no bugs, what exceptions could be raised by the following line of code?
user = register_new_user(params)
It’s hard to know, without looking at the implementation:
def register_new_user(params)
new_user = User.new(params)
authorize! :create, new_user
new_user.save!
send_welcome_email(new_user)
end
Even after reading the implementation, it’s still difficult to know for sure. Some of the possible exceptions would be obvious to a Rails developer, and you can probably guess which methods could throw other exceptions, but exceptions could be raised by any method call, any ActiveRecord callback, any third-party gem being used – the possibilities are endless.
Which exceptions are expected error cases, and which ones are unexpected bugs?
Which ones should be rescue
d, and which ones shouldn’t?
The answer is not clear.
Exceptions are completely implicit, and that makes them hard to predict. If you can’t predict all the expected error cases, then error cases will not be handled well. Unpredictable code leads to bugs.
If you’re interested to see a good use for exceptions, see Raise On Developer Mistake.
John Nunemaker recently published Resilience in Ruby: Handling Failure, which has good demonstrations of these issues.
This is why developers hate writing exception handling code, in basically every programming language. Ask Java developers how they feel about checked exceptions. Anders Hejlsberg, the lead architect of C#, has this to say:
In a lot of cases, people don’t care. They’re not going to handle any of these exceptions. There’s a bottom level exception handler [that] is just going to bring up a dialog that says what went wrong […] but they’re not actually interested in handling the exceptions.
Introducing Result Objects
Now I want to introduce a different way of handling errors using result objects from the Resonad gem. The key differences are:
-
Errors are part of the return value, not an exception.
-
It separates expected error cases from unexpected bugs. Expected errors are available through
result.error
, and unexpected bugs are exceptions. -
All expected error cases are automatically “caught,” without having to guess what they are.
-
The design makes it difficult to “forget” to handle error cases.
The calling method would look something like this:
result = register_new_user(params)
if result.success?
handle_success(result.value)
else
handle_failure(result.error)
end
And the implementation would look something like this:
def register_new_user(params)
authorize(:create, User.new(params))
.and_then { |user| save_model(user) }
.on_success { |user| send_welcome_email(user) }
end
def authorize(permission, model)
authorize! permission, model
Resonad.Success(model)
rescue AuthorizationFailed => error
Resonad.Failure(error)
end
def save_model(model)
if model.save
Resonad.Success(model)
else
Resonad.Failure(model.errors)
end
end
def send_welcome_email(user)
UserMailer.welcome(user).deliver_now
end
These Resonad
result objects are wrappers for either a successful value, or an error.
If result.successful?
returns true
, then result.value
is the successful value.
Otherwise result.error
will contain some kind of error description.
The on_success
method is used for causing side effects without affecting the result.
In the code above, it indicates that send_welcome_email
is expected to always succeed.
If it does fail, by raising an exception, that is an unexpected bug.
The authorize
and save_model
methods have expected error cases.
They return either Resonad.Success
or Resonad.Failure
.
It is not a bug when these methods return Resonad.Failure
.
The app should handle these kinds of failures, and recover/respond appropriately.
Any methods that return result objects can be chained together with and_then
.
The and_then
method will only run its block on success, and skip the block on failure.
Failures at any stage will be passed down the chain, untouched.
As an example, since register_new_user
also returns a Resonad
, it could be chained even further:
check_registrations_are_open
.and_then { register_new_user(params) }
.and_then { |user| create_placeholder_data_for(user) }
Appropriate Uses
There is no need to use a result object:
-
When the method should always succeed. Instead, just throw an exception to indicate that there is a bug.
-
When there is only a single failure case. You can just return
nil
,false
, or some other sentinel value to indicate failure. -
When the error is locally recoverable. If you can recover from the error, the method doesn’t need to fail. Sometimes it’s appropriate to just return a null object, an empty array, or maybe some dummy data.
Result objects are appropriate:
-
When an operation can fail in multiple different ways.
-
When operations need to be chained together, and each individual operation can fail.
-
When it’s important for callers to know that the method could possibly fail, because they strongly encourage the developer to check for error cases.
In a web app, result objects work well with service/interactor/command objects. The business logic in this area often includes many different failure cases that need to be handled. It’s also common for units of business logic to be chained together.
Available Implementations
Result objects are not a new idea. You can find implementations in multiple languages by searching for “result monad”. In fact, “Resonad” is a combination of “result” and “monad”.
Here is a list of Ruby implementations:
-
Resonad
This is my newly-released gem, and the one used in the code examples of this article. -
dry-monads
This gem contains a collection of different monads. TheEither
monad can be used as a result object, whereRight
is success andLeft
is failure. Also take a look at theTry
monad, which is similar, but captures exceptions. These monads can by chained together with dry-transaction. -
GitHub::Result
This is the implementation used in John Nunemaker’s article, and is part of thegithub-ds
gem. -
monadic
Has anEither
monad, inspired by Scala. -
result-monad
I just found this recently, but it looks very similar to Resonad. -
Write your own.
Honestly, it’s not much work to write a result object class. A custom implementation might fit your project better, and give you one less gem dependency.
Got questions? Comments? Milk?
Shoot an email to [email protected] or hit me up on Twitter (@tom_dalling).