monad-oxide
is a Ruby take on Monads seen in Rust’s =Std= package. Primarily
this means =Result= and =Option=.
You can use these structures in Ruby to achieve more reliable error handling and
results processing. Result
and Option
are from the Monad family, which is
essentially “programmable semicolons” - sequential operations which can be
chained and have an error flow to them.
This library attempts to account for some Rubyisms and will diverge from Rust’s approach on occasion.
For a great introductory talk showing the value of these constructs, see Railway
oriented programming: Error handling in functional languages by Scott Wlaschin.
The talk is about an hour long, but has a great comparison of imperative vs.
functional error handling. In this library, Either
is essentially the same as
Result
, and Maybe
the same as Option
.
Example Disclaimer:
Despite lots of these examples using unwrap
and unwrap_err
, these are highly
recommended not be used because it breaks out of the monad chain in a dangerous
way. Exceptions can be raised, which unwinds your stack - you’re basically back
where you started without monad-oxide
. These methods are provided for
convenience and testing, but it might be necessary to use these in some
production instances. If so, code defensively around their usages and try to use
the methods sparingly. Having a dedicated quarantine zone around your unwrap
and unwrap_err
is the most preferable.
Changing the inner value with map
:
require 'monad-oxide'
MonadOxide.ok('test')
.map(->(s) { s.upcase() })
.unwrap() # 'test'
TEST
Changing the Result
type based on input:
require 'monad-oxide'
MonadOxide.ok('test')
.and_then(->(s) {
if s =~ /e/
MonadOxide.err(ArgumentError.new("Can't have 'e' in test data."))
else
MonadOxide.ok(s.upcase())
end
})
.unwrap_err() # The above ArgumentError.
#<ArgumentError: Can't have 'e' in test data.>
Inspecting a value in the chain without changing it:
require 'logger'
require 'monad-oxide'
logger = Logger.new(STDERR)
MonadOxide.ok('test')
.and_then(->(s) {
if s =~ /e/
MonadOxide.err(ArgumentError.new("Can't have 'e' in test data."))
else
MonadOxide.ok(s.upcase())
end
})
.inspect_err(logger.method(:error)) # Print any errors to the error log.
.inspect_ok(logger.method(:info)) # Print any non-errors to the info log.
.unwrap_err() # The above ArgumentError.
#<ArgumentError: Can't have 'e' in test data.>
You can check to see if you’re working with an Ok
or Err
with ok?
and
err?
. Generally these should see the most use with your unit tests in the
form of expect(foo).to(be_ok())
and expect(bar).to(be_err()))
. It is
advised to favor mechanisms like #match
or one of the safe #unwrap
variants
when attempting to do conditional work based on the Result
subtype, rather
than adding branching paths.
require 'monad-oxide'
MonadOxide.ok('foo').ok?()
true
require 'monad-oxide'
MonadOxide.ok('foo').err?()
false
require 'monad-oxide'
MonadOxide.err('foo').ok?()
false
require 'monad-oxide'
MonadOxide.err('foo').err?()
true
And with rspec
. There’s some difficulty in finding the right incantation to
call into rspec
without actually running rspec
, or getting rspec
to be the
“ruby” runner for org-babel
, so results aren’t shown.
require 'monad-oxide'
expect(MonadOxide.ok('foo')).to(be_ok) # Pass.
expect(MonadOxide.ok('foo')).to(be_err) # Fail.
expect(MonadOxide.err('foo')).to(be_ok) # Fail.
expect(MonadOxide.err('foo')).to(be_err) # Pass.
Unwrapping is the act of accessing the value inside the Result
. It is often
considered dangerous because it raises exceptions - an action counter to the
whole purpose of monad-oxide
. However, there are variants documented below to
make the operation safe.
unwrap
and unwrap_err
both access inner Ok
and Err
data, respectively.
If the Result
is mismatched (unwrap
with Err
or unwrap_err
with Ok
),
an MonadOxide::UnwrapError
is raised.
It is recommended to only use this for debugging purposes and instead seek
better, more functional uses in Result
to work with the data in the Result
.
Ok
with unwrap
just gets the data.
require 'monad-oxide'
MonadOxide.ok('foo').unwrap()
foo
Err
with unwrap
raises UnwrapError
:
require 'monad-oxide'
begin
MonadOxide.err('foo').unwrap()
rescue => e
e.inspect()
end
#<MonadOxide::UnwrapError: MonadOxide::Err with "foo" could not be unwrapped as an Ok.>
Ok
with unwrap_err
raises UnwrapError
:
require 'monad-oxide'
begin
MonadOxide.ok('foo').unwrap_err()
rescue => e
e.inspect()
end
#<MonadOxide::UnwrapError: MonadOxide::Ok with "foo" could not be unwrapped as an Err.>
Err
with unwrap_err
just gets the data.
require 'monad-oxide'
MonadOxide.err('foo').unwrap_err()
foo
unwrap_or
provides a safe means of unwrapping via a fallback value that is
provided to unwrap_or
.
For Ok
, unwrap_or
provides the value in the Ok
.
require 'monad-oxide'
MonadOxide.ok('foo').unwrap_or('bar')
foo
For Err
, unwrap_or
provides the value passed to unwrap_or
.
require 'monad-oxide'
MonadOxide.err('foo').unwrap_or('bar')
bar
unwrap_or_else
and unwrap_err_or_else
both access inner Ok
and Err
data,
respectively. If the Result
is mismatched (unwrap_or_else
with Err
or
unwrap_err_or_else
with Ok
), the provided function or block is evaluated and
its return value is returned.
This unwrap
is safe because there is always a value returned.
Ok
with unwrap_or_else
just gets the data.
require 'monad-oxide'
MonadOxide.ok('foo').unwrap_or_else(->() { 'bar' })
foo
Err
with unwrap_or_else
returns the value from the provided function:
require 'monad-oxide'
MonadOxide.err('foo').unwrap(->(){ 'bar' })
bar
Ok
with unwrap_err_or_else
returns the value from the provided function:
require 'monad-oxide'
MonadOxide.ok('foo').unwrap_err_or_else(->() { 'bar' })
bar
Err
with unwrap_err_or_else
just gets the data.
require 'monad-oxide'
MonadOxide.err('foo').unwrap_err(->() { 'bar' })
foo
You can use #into_result
to convert an Array
of Results
to Result
of an
Array
.
require 'monad-oxide'
[
MonadOxide.ok('foo'),
MonadOxide.ok('bar'),
]
.into_result()
.unwrap()
["foo", "bar"]
#into_result
will provide an Err
if any of the elements in the Array
are
Err
.
require 'monad-oxide'
[
MonadOxide.ok('foo'),
MonadOxide.err('bar'),
MonadOxide.ok('baz'),
MonadOxide.err('qux'),
]
.into_result()
.unwrap_err()
["bar", "qux"]
Complex operation:
require 'logger'
require 'monad-oxide'
class AppError < Exception; end
logger = Logger.new(STDERR)
MonadOxide.ok('test')
.and_then(->(s) {
if s =~ /e/
MonadOxide.err(ArgumentError.new("Can't have 'e' in test data."))
else
MonadOxide.ok(s.upcase())
end
})
.map(->(s) { s.trim() }) # Won't actually get called due to error.
.inspect_err(logger.method(:error)) # Print any errors to the error log.
.inspect_ok(logger.method(:info)) # Print any non-errors to the info log.
.or_else(->(e) {
if e.kind_of?(ArgumentError)
# Convert to an app-specific error for ArgumentErrors.
MonadOxide.err(AppError.new(e))
else
# For other errors, just chain it along. Backtrace will be preserved.
MonadOxide.err(e)
end
})
.unwrap_err() # The above AppError containing an ArgumentError.
https://github.com/mxhold/opted has similar aims to monad-oxide
- essentially
a Rust port of the Result
type.
This would complete the Rust monads that I know of. Option
is Rust’s answer
to nil
.
This covers methods needed on Array
, and translation methods between Result
and Option
, as well as boolean operations. This can all be done as separate
work from general Option
support as well as separate from each other.
If we did <<
and >>
for Result
, we should repeat that for Option
as
well.
We can run yard
documentation generation locally but I don’t think we can push
it to the official docs site (I don’t have a link handy, and am offline). That
said, I have seen generated documentation. I have a ticket open for this, and
should see if it needs to be closed. I don’t have the link handy either.
or
, and
, etc should be supported. In addition, we can support ||
and &&
and maybe some others that Ruby allows.
We could override <<
and >>
to mean and_then
and or_else
. I’d have to
see what that looks like. Granted this isn’t in the Rust capabilities, but it
might be fitting for Ruby. Those who don’t want the syntax are not compelled to
use it.
I don’t know that this is reasonably doable in Ruby, but I admit it’s handy in
Rust. Being able to handle the unwrap-or-return-err-early behavior would be
nice even if it didn’t look as pretty as ?
. We could allow decoration on a
method (monad_oxide(:method)
) which rescues a special, internal Exception
.
I think with actual Ruby exception handling this could be easy to mess up.
Perhaps we could do some meta programming where we force the early return via
binding?
In any case, some equivalent to this could be nice.
We should strive to stay in feature parity with Rust’s Option
and Result
but
I don’t have an exhaustive list right now. Currently it’s very nearly complete.
It would be helpful to have a tally and a documented percentage of parity.