LoganBarnett / monad-oxide

Ruby port of Rust's Result and Option.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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

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"]

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.

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.

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.

License:MIT License


Languages

Language:Ruby 99.4%Language:Nix 0.6%