Resonad
Lightweight, functional "result" objects that can be used instead of exceptions.
Read: Result Objects - Errors Without Exceptions
Typical Usage Example
Assuming each method returns a Resonad
(Ruby 2.7 syntax):
find_widget(widget_id)
.and_then { update_widget(_1) }
.on_success { logger.info("Updated #{_1}" }
.on_failure { logger.warn("Widget update failed because #{_1}") }
Success Type
A value that represents success. Wraps a value
that can be any arbitrary
object.
result = Resonad.Success(5)
result.success? #=> true
result.failure? #=> false
result.value #=> 5
result.error #=> raises an exception
Failure Type
A value that represents a failure. Wraps an error
that can be any arbitrary
object.
result = Resonad.Failure(:buzz)
result.success? #=> false
result.failure? #=> true
result.value #=> raises an exception
result.error #=> :buzz
Mapping
Non-destructive update for the value
of a Success
object. Does nothing to
Failure
objects.
The block takes the value
as an argument, and returns the new value
.
result = Resonad.Success(5)
.map { _1 + 1 } # 5 + 1 -> 6
.map { _1 + 1 } # 6 + 1 -> 7
.map { _1 + 1 } # 7 + 1 -> 8
result.success? #=> true
result.value #=> 8
result = Resonad.Failure(:buzz)
.map { _1 + 1 } # not run
.map { _1 + 1 } # not run
.map { _1 + 1 } # not run
result.success? #=> false
result.error #=> :buzz
Aliases
Lots of the Resonad methods have aliases.
Personally, I can never remember if it's success?
or successful?
or ok?
,
so let's just do it the Ruby way and allow all of them.
# >>> object creation aliases (same for failure) <<<
result = Resonad.Success(5)
result = Resonad.success(5) # lowercase, for those offended by capital letters
result = Resonad::Success[5] # class constructor method
# >>> success aliases <<<
result.success? #=> true
result.successful? #=> true
result.ok? #=> true
# >>> failure aliases <<<
result.failure? #=> false
result.failed? #=> false
result.bad? #=> false
# >>> mapping aliases <<<
result.map { _1 + 1 } #=> Success(6)
result.map_value { _1 + 1 } #=> Success(6)
# >>> flat mapping aliases <<<
result.and_then { Resonad.Success(_1 + 1) } #=> Success(6)
result.flat_map { Resonad.Success(_1 + 1) } #=> Success(6)
# >>> error flat mapping aliases <<<
result.or_else { Resonad.Failure(_1 + 1) } # not run
result.otherwise { Resonad.Failure(_1 + 1) } # not run
result.flat_map_error { Resonad.Success(_1 + 1) } # not run
# >>> conditional tap aliases <<<
# pattern: (on_|if_|when_)(success_alias|failure_alias)
result.on_success { puts "hi" } # outputs "hi"
result.if_success { puts "hi" } # outputs "hi"
result.when_success { puts "hi" } # outputs "hi"
result.on_ok { puts "hi" } # outputs "hi"
result.if_ok { puts "hi" } # outputs "hi"
result.when_ok { puts "hi" } # outputs "hi"
result.on_successful { puts "hi" } # outputs "hi"
result.if_successful { puts "hi" } # outputs "hi"
result.when_successful { puts "hi" } # outputs "hi"
result.on_failure { puts "hi" } # not run
result.if_failure { puts "hi" } # not run
result.when_failure { puts "hi" } # not run
result.on_bad { puts "hi" } # not run
result.if_bad { puts "hi" } # not run
result.when_bad { puts "hi" } # not run
result.on_failed { puts "hi" } # not run
result.if_failed { puts "hi" } # not run
result.when_failed { puts "hi" } # not run
Flat Mapping (a.k.a. and_then
)
Non-destructive update for a Success
object. Either turns it into another
Success
(can have a different value
), or turns it into a Failure
. Does
nothing to Failure
objects.
The block takes the value
as an argument, and returns a Resonad
(either
Success
or Failure
).
result = Resonad.Success(5)
.and_then { Resonad.Success(_1 + 1) } # updates to Success(6)
.and_then { Resonad.Failure("buzz #{_1}") } # updates to Failure("buzz 6")
.and_then { Resonad.Success(_1 + 1) } # not run (because it's a failure)
.error #=> "buzz 6"
# also has a less-friendly but more-technically-descriptive alias: `flat_map`
result.flat_map { Resonad.Success(_1 + 1) }
This is different to Ruby's #then
method added in 2.6. The block for #then
would take a Resonad argument, regardless of whether it's Success
or
Failure
. The block for #and_then
takes a Success
object's value, and
only runs on Success
objects, not Failure
objects.
Error Mapping
Just as Success
objects can be chained with #map
and #and_then
, so can
Failure
objects with #map_error
and #or_else
. This isn't used as often,
but has a few use cases such as:
# Use Case: convert an error value into another error value
make_http_request #=> Failure(404)
.map_error { |status_code| "HTTP #{status_code} Error" }
.error #=> "HTTP 404 Error"
# Use Case: recover from error, turning into Success
load_config_file #=> Failure(:config_file_missing)
.or_else { try_recover_from(_1) }
.value #=> { :setting => 'default' }
def try_recover_from(error)
if error == :config_file_missing
Resonad.Success({ setting: 'default' })
else
Resonad.Failure(error)
end
end
Conditional Tap
If you're in the middle of a long chain of methods, and you don't want to break
the chain to run some kind of side effect, you can use the #on_success
and
#on_failure
methods. These run an arbitrary block code, but do not affect the
result object in any way. They work like Ruby's #tap
method, but Failure
objects will not run on_success
blocks, and Success
objects will not run
on_failure
blocks.
do_step_1
.and_then { do_step_2(_1) }
.and_then { do_step_3(_1) }
.on_success { puts "Successful step 3 result: #{_1}" }
.and_then { do_step_4(_1) }
.and_then { do_step_5(_1) }
.on_failure { puts "Uh oh! Step 5 failed: #{_1} }
.and_then { do_step_6(_1) }
.and_then { do_step_7(_1) }
There are lots of aliases for these methods. See the "Aliases" section above.
Callable Object Arguments
Anywhere that you can use a block argument, you have the ability to provide a callable object instead.
For example, this block argument:
Resonad.Success(42).map { |x| x * 2 }
#=> 84
Could also be given as an object that implements #call
:
class Doubler
def call(x)
x * 2
end
end
Resonad.Success(42).map(Doubler.new)
#=> 84
Pattern Matching Support
If you are using Ruby 2.7 or later, you can pattern match on Resonad objects. For example:
case result
in { value: } # match any Success
puts value
in { error: :not_found } # match Failure(:not_found)
puts "Thing not found"
in { error: String => msg } # match any Failure with a String error
puts "Failed to fetch thing because #{msg}"
in { error: } # match any Failure
raise "Unhandled error: #{error.inspect}"
end
Resonad.Success(5)
deconstructs to:
- Hash:
{ value: 5 }
- Array:
[:success, 5]
And Resonad.Failure('yikes')
deconstructs to:
- Hash:
{ error: 'yikes' }
- Array:
[:failure, 'yikes']
Automatic Exception Rescuing
If no exception is raised, wraps the block's return value in Success
. If an
exception is raised, wraps the exception object in Failure
.
def try_divide(top, bottom)
Resonad.rescuing_from(ZeroDivisionError) { top / bottom }
end
yep = try_divide(6, 2)
yep.success? #=> true
yep.value #=> 3
nope = try_divide(6, 0)
nope.success? #=> false
node.error #=> #<ZeroDivisionError: ZeroDivisionError>
Convenience Mixin
If you're tired of typing "Resonad." in front of everything, you can include
the Resonad::Mixin
mixin.
class RobotFortuneTeller
include Resonad::Mixin
def next_fortune
case rand(0..100)
when 0..70
# title-case constructor from Resonad::Mixin
Success("today is auspicious")
when 71..95
# lower-case constructor from Resonad::Mixin
success("ill omens abound")
else
# direct access to classes from Resonad::Mixin
Failure.new("MALFUNCTION")
end
end
end
Note that Resonad::Mixin
provides private methods, and private constants, so
you can't do this:
RobotFortuneTeller.new.Success(5)
#=> NoMethodError: private method `Success' called for #<RobotFortuneTeller:0x00007fe7fc0ff0c8>
RobotFortuneTeller::Success
#=> NameError: private constant Resonad::Mixin::Success referenced
If you want the methods/constants to be public, then use Resonad::PublicMixin
instead.
Contributing
Bug reports and pull requests are welcome on GitHub at: https://github.com/tomdalling/resonad
I'm open to PRs that make the gem more convenient, or that makes calling code read better.
Make sure your PR has full test coverage.