Skip to content

LoganBarnett/monad-oxide

Repository files navigation

monad-oxide

Summary

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.

Examples

Result

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.

map

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.>

inspect

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.>

<kind>? checks

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

with rspec

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

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

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

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

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

arrays

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

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>

promises/futures

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 operations

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

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.

mapping

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

monadic and_then

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

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.

map

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?() # true
true

map_none

Transforming a None into a Some:

require 'monad-oxide'

MonadOxide.none()
  .map_none(->() { 'default' })
  .unwrap() # 'default'
default

and_then

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

or_else

Providing a fallback for None:

require 'monad-oxide'

MonadOxide.none()
  .or_else(->() { MonadOxide.some('fallback') })
  .unwrap() # 'fallback'
fallback

inspect

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

<kind>? checks

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

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

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()

option helper

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

match

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

Honorable Mentions

https://github.com/mxhold/opted has similar aims to monad-oxide - essentially a Rust port of the Result type.

Roadmap

Add Option

This would complete the Rust monads that I know of. Option is Rust’s answer to nil.

Add Advanced Option Functionality

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.

Add Examples for All Either Operations

Result operations:

  • [X] map
  • [X] and_then
  • [ ] or_else
  • [ ] unwrap et. al.
  • [ ] inspect
  • [ ] rspec with be_left and be_right.

Add Examples for All Result Operations

Result operations:

  • [X] map
  • [ ] and_then
  • [ ] or_else
  • [X] unwrap et. al.
  • [X] inspect
  • [ ] rspec with be_ok and be_err.

Check on Documentation Generation

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.

Support boolean operators

or, and, etc should be supported. In addition, we can support || and && and maybe some others that Ruby allows.

Support bind operator for Result?

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.

Support ? equivalent

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.

Additional Feature Parity

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.

About

Ruby port of Rust's Result and Option.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages