Lab42::Result
A result encapsulation class, like the Either
type in Haskell.
Context Quick Starting Guide
Given
require "lab42/result/autoimport"
let(:ok) { Result.ok(42) }
let(:error) { Result.error("oh no!") }
Accessing a result
If OK
Then it's ok (I guess):
expect(ok).to be_ok
And its value might be of interest
expect(ok.value).to eq(42)
And it will execute the block passed to the if_ok
method
x = nil
expect(ok.if_ok {x = 42}).to eq(42)
expect(x).to eq(42)
And the value is passed in
expect(ok.if_ok{it/2}).to eq(21)
But not the one passed to the if_error
method
x = nil
expect(ok.if_error {x = 42}).to be_nil
expect(x).to be_nil
And will not raise any error
expect{ok.raise!}.not_to raise_error
But you must not access the error method
expect{ ok.error }.to raise_error(Lab42::Result::IllegalMonitorState, /must not invoke the error method on an ok result/)
And the same holds for the exception method
expect{ ok.exception }.to raise_error(Lab42::Result::IllegalMonitorState, /must not invoke the exception method on an ok result/)
If Error
But it is not an error
expect(error).not_to be_ok
And its value cannot be accessed anymore
expect{ error.value }.to raise_error(Lab42::Result::IllegalMonitorState, /must not invoke the value method on an error result/)
But of course now we can call the error methods #error
and #exception
expect(error.error).to eq("oh no!")
expect(error.exception).to eq(RuntimeError)
And it will certainly raise this time
expect{ error.raise! }.to raise_error(RuntimeError, "oh no!")
And as often times you will match on an error case only and raise a custom exception the following shortcut comes in handy
expect{ error.raise!(KeyError) }.to raise_error(KeyError, "oh no!")
And also you might like to have access to the original message
expect{ error.raise!(KeyError) { "key not found #{it}"} }
.to raise_error(KeyError, "key not found oh no!")
And it will execute the block passed to the if_error
method
x = nil
expect(error.if_error {x = 42}).to eq(42)
expect(x).to eq(42)
And the error and message are passed in
expect(error.if_error {[_1, _2]}).to eq([RuntimeError, "oh no!"])
But not the one passed to the if_ok
method
x = nil
expect(error.if_ok {x = 42}).to be_nil
expect(x).to be_nil
Context: Capturing Exceptions
Given some code which might raise exceptions
class MyError < StandardError; end
def maybe_raise(answer)
raise MyError, "Not the correct answer" unless answer == 42
"Correct answer!"
end
Then you can rescue from the exception with
error = Result.from_rescue{maybe_raise(73)}
expect(error).not_to be_ok
And you can decostruct the error
Result.from_rescue{maybe_raise(73)} => {ok: false, error:}
expect(error).to eq("Not the correct answer")
But if you get the correct answer
Result.from_rescue{maybe_raise(42)} => {ok: true, value:}
expect(value).to eq("Correct answer!")
Context: More params for our constructors
Context: Ok without a value
Given an ok result with the default value
let(:default_ok) { Result.ok }
Then we still have an ok result
expect(default_ok).to be_ok
But its value is just nil
expect(default_ok.value).to be_nil
Context: Error with a different exception
Given an error with an explicit exception
let(:argument_error) { Result.error("do not do that", exception: ArgumentError) }
Then we will get that exception back
expect{ argument_error.raise! }
.to raise_error(ArgumentError, "do not do that")
Context: Pattern Matching and Conversions
While the Result
objectr is very strict on what methods can be called depending on its status (ok?, !ok?)
A more laissez-faire approach can be achieved via Pattern Matching and Conversions
We can deconstruct a result into a hash or an array, and the deconstruction into
a hash is identical to matching the result of #to_h
Given two results
let(:my_error) { Result.error("my bad") }
let(:my_success) { Result.ok("my good") }
let(:error_hash) { my_error.to_h }
let(:success_hash) { my_success.to_h }
Then we can convert them into hashes
expect(error_hash).to eq(ok: false, value: nil, error: "my bad", exception: RuntimeError)
expect(success_hash).to eq(ok: true, value: "my good", error: nil, exception: nil)
Then we can desconstruct the error as a hash (as seen above) ```ruby
my_error in {ok: false, error: message, exception: RuntimeError}
expect(message).to eq("my bad")
The deconstruction into an array however will yield two differently shaped patterns
And therefore...
```ruby
my_error in [false, message, RuntimeError]
expect(message).to eq("my bad")
And the same holds for the ok result
my_success in [true, value]
expect(value).to eq("my good")
Context: Immutability
And last but not least, to assure that all instances of Result
are frozen we have removed the
default constructor (we have not - yet - shadowed Object#allocate
though)
Given results
Then we do not have a default constructor
expect{ Result.new }.to raise_error(NoMethodError)
Context: Close Integration
Although the Result class has its merits for error handling it
also encourages the usage of a pattern that I call Ok iff errors.empty?
This pattern is implemented by the following module
module OkIffErrorsEmpty
def errors = ( @__errors__ ||= [])
def ok? = errors.empty?
end
As simple as that.
Now Result allows for a seamingless integration with this pattern, first of all it
exposes a module implementing it, but with a (more) reasonable name Errors
While this makes for a fatter interface, if well used, can make the workflow
in your class more elegant
Given a class including Errors (pun intended)
require 'lab42/result/errors'
class MyErrors
include Lab42::Result::Errors
end
let(:my_errors) { MyErrors.new }
Then an instance of MyErrors is just ok
expect(my_errors).to be_ok
And has no errors
expect(my_errors.errors).to be_empty
And we can extract a result out of it
result = my_errors.to_result
expect(result).to be_ok
And the value of that result is (per default) my_errors
my_errors.to_result => {value: my_errors}
But if there are errors, we get
my_errors.errors << :error1
my_errors.errors << :error2
my_errors.to_result => [false, errors, _]
expect(errors).to eq([:error1, :error2])
And eventually we get a helper to add many errors at once (always an optimist)
my_errors.add_errors(:error3, :error4)
my_errors.to_result => [false, errors, _]
expect(errors).to eq([:error3, :error4])
But I kept my favorite at the end (bad misstake, the audience is sleeping already)
great = MyErrors.new
output = []
great.if_ok { output << "I was completly fine" }
great.if_error { raise "Does not happen" }
great.add_errors("I got sick")
great.if_ok { raise "Still not happening" }
great.if_error { output << "But then, caught something" }
expect(output).to eq(["I was completly fine" , "But then, caught something"])
Even more detailed speculations
...which allow to have an acceptance test provided test coverage and show
the detailed behavior of this library can be found here
LICENSE
Copyright 2025 Robert Dober robert.dober@gmail.com
AGPL-3.0-or-later c.f LICENSE