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.
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"]
try is a way of taking some arbitrary code and converting the returned value
into a Ok or the raised error as an Err. This is for convenience to get you
started in a Result chain without an artificial
MonadOxide.ok(nil).and_then(...).
The positive case:
require 'monad-oxide'
r = MonadOxide::Result.try(->() {
return 1
})
r.unwrap()1
require 'monad-oxide'
r = MonadOxide::Result.try(->() {
raise StandardError.new('bar')
})
r.unwrap_err()#<StandardError: bar>
If you are using conccurent-ruby, monad-oxide supplies a helper you can use to
coerce a Future into a Result. You need to require the
monad-oxide/concurrent-promsies file to get this, independently from your
monad-oxide require.
require 'monad-oxide'
require 'monad-oxide/concurrent-promises'
puts(
Concurrent::Promises.future() {
'foo'
}
.into_result()
.unwrap(),
)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.Either represents a dual state where neither branch is given a bias (such as
Result’s Ok and Err representing success and error branches). Either
uses the arbitrary Left and Right.
This type ultimately is inspired by Haskell’s Either, which uses Left for
non-preferable branches and Right for preferable branches (“Right” being a
synonym for “correct”).
One use case is to tally up results of some computation, where some of the
results are non-fatal errors that should be ignored for future calculations. In
such a case you would produce Lefts for the errors (or maybe even skips) and
Rights for data that can move forward to the next steps.
Changing the inner value with map_<direction>:
require 'monad-oxide'
MonadOxide.left('test')
.map_left(->(s) { s.upcase() })
.unwrap_left() # 'TEST'TEST
require 'monad-oxide'
MonadOxide.right('test')
.map_right(->(s) { s.upcase() })
.unwrap_right() # 'TEST'TEST
But note how changes don’t happen when the sides are mismatched:
require 'monad-oxide'
MonadOxide.left('test')
.map_right(->(s) { s.upcase() })
.unwrap_left() # 'test'test
require 'monad-oxide'
MonadOxide.right('test')
.map_left(->(s) { s.upcase() })
.unwrap_right() # 'test'test
Note how Either#unwrap_right must be used:
require 'monad-oxide'
MonadOxide.left('test')
.left_and_then(->(s) { Either.right(s.upcase()) })
.unwrap_right() # 'TEST'And the reverse:
require 'monad-oxide'
MonadOxide.right('test')
.right_and_then(->(s) { Either.Left(s.upcase()) })
.unwrap_left() # 'TEST'Option represents a value that may or may not be present. It has two
variants: Some for when a value is present, and None for when it is not.
This is similar to Rust’s Option type and provides a safer alternative to
using nil.
Changing the inner value with map:
require 'monad-oxide'
MonadOxide.some('test')
.map(->(s) { s.upcase() })
.unwrap() # 'TEST'TEST
But note how map falls through for None:
require 'monad-oxide'
MonadOxide.none()
.map(->(s) { s.upcase() })
.none?() # truetrue
Transforming a None into a Some:
require 'monad-oxide'
MonadOxide.none()
.map_none(->() { 'default' })
.unwrap() # 'default'default
Chaining operations that return Option:
require 'monad-oxide'
MonadOxide.some('test')
.and_then(->(s) {
if s.length > 3
MonadOxide.some(s.upcase())
else
MonadOxide.none()
end
})
.unwrap() # 'TEST'TEST
Providing a fallback for None:
require 'monad-oxide'
MonadOxide.none()
.or_else(->() { MonadOxide.some('fallback') })
.unwrap() # 'fallback'fallback
Inspecting values without changing them:
require 'logger'
require 'monad-oxide'
logger = Logger.new(STDERR)
MonadOxide.some('test')
.inspect_some(logger.method(:info))
.inspect_none(->() { logger.warn('No value!') })
.unwrap() # 'test'test
You can check to see if you’re working with a Some or None with some?
and none?. Generally these should see the most use with your unit tests in
the form of expect(foo).to(be_some()) and expect(bar).to(be_none())).
require 'monad-oxide'
MonadOxide.some('foo').some?()true
require 'monad-oxide'
MonadOxide.some('foo').none?()false
require 'monad-oxide'
MonadOxide.none().some?()false
require 'monad-oxide'
MonadOxide.none().none?()true
Unwrapping is the act of accessing the value inside the Option. It is often
considered dangerous because it raises exceptions for None values.
unwrap and unwrap_none both access inner Some and None data,
respectively. If the Option is mismatched (unwrap with None or
unwrap_none with Some), a MonadOxide::UnwrapError is raised.
It is recommended to only use this for debugging purposes.
Some with unwrap just gets the data.
require 'monad-oxide'
MonadOxide.some('foo').unwrap()foo
None with unwrap raises UnwrapError:
require 'monad-oxide'
begin
MonadOxide.none().unwrap()
rescue => e
e.inspect()
end#<MonadOxide::UnwrapError: MonadOxide::None could not be unwrapped as a Some.>
Some with unwrap_none raises UnwrapError:
require 'monad-oxide'
begin
MonadOxide.some('foo').unwrap_none()
rescue => e
e.inspect()
end#<MonadOxide::UnwrapError: MonadOxide::Some with "foo" could not be unwrapped as a\nNone.\n>
None with unwrap_none returns nil.
require 'monad-oxide'
MonadOxide.none().unwrap_none()The option helper converts nil to None and any other value to Some:
require 'monad-oxide'
MonadOxide.option(nil).none?()true
require 'monad-oxide'
MonadOxide.option('foo').unwrap()foo
Pattern matching over Some and None:
require 'monad-oxide'
MonadOxide.some('test')
.match({
MonadOxide::Some => ->(x) { "Value: #{x}" },
MonadOxide::None => ->() { "No value" }
})Value: test
require 'monad-oxide'
MonadOxide.none()
.match({
MonadOxide::Some => ->(x) { "Value: #{x}" },
MonadOxide::None => ->() { "No value" }
})No value
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.
Result operations:
- [X]
map - [X]
and_then - [ ]
or_else - [ ]
unwrapet. al. - [ ]
inspect - [ ]
rspecwithbe_leftandbe_right.
Result operations:
- [X]
map - [ ]
and_then - [ ]
or_else - [X]
unwrapet. al. - [X]
inspect - [ ]
rspecwithbe_okandbe_err.
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.