Libraries

While Ruby may not be the cool new kid on the block anymore, there's barely been a better time to be a Rubyist. This is not only due to constant language improvements, but also because of a new generation of gems that are framework agnostic and are designed around plain old Ruby objects (PORO). The dry-rb collection of gems is a great example of this approach, and in this article, we'll explore how dry-monads can help with modeling complex data transformations robustly.

The M-Word: Monad Basics

In many programming circles, monads are seen as arcane constructs that are only of interest to academics. This is unfortunate since they are not all that complicated: essentially monads are just a way to perform a series of computations within a "context." To do this, a "plain" value like a string or integer is first "wrapped" by a function called return in FP jargon. These wrapped values are then combined with an operation called bind , which removes their wrapping, performs the desired operation on their underlying value, and rewraps the result. In the end, the context is removed again, often through a concept called pattern-matching.

dry-monad in Action

All of this may still sound too theoretical, so let's look at the Maybe monad as a concrete example. Maybe 's purpose is to model a series of computations that could return nil at any intermediate step. Instead of mixing business logic with repeated error checks, we "wrap" our starting value in a Maybe , perform all our operations, and only check the result at the very end, where it will either be of the form Some(value) when everything went according to plan, or None when a nil was encountered.

In a web application this could, for example, be used to return the uppercase version of a user's name, or the default value "ANONYMOUS USER" if there's no currently logged-in user or the name isn't set. Let's look at this example step by step. First, we require the Maybe monad and alias the Dry::Monads module to M to save ourselves some typing. We also set up a dummy user:

require 'dry/monads/maybe' M = Dry::Monads current_user = nil

Our maybe_name function first "wraps" the user in a Maybe context and uses the bind method to apply a block to this monadic value. Inside the block, we try to access the user's name and then repeat the same process to finally call upcase on it:

def maybe_name (user) M.Maybe(user).bind do |u| M.Maybe(u.name).bind do |n| M.Maybe(n.upcase) end end end maybe_name(current_user)

Note that this doesn't require any specific checks for nil values. If nil is encountered Maybe returns None , which all subsequent steps will just pass through without trying to perform any further operations on it.

To extract the actual result we have two choices: the unsafe value! method, which will raise an error for None values, or the preferred value_or alternative, which allows the caller to specify a sensible default value:

maybe_name(current_user).value! maybe_name(current_user).value_or( "ANONYMOUS USER" )

Now let's try this again with an actual user:

user = OpenStruct.new( name: "john monadoe" ) maybe_name(current_user) maybe_name(current_user).value! maybe_name(current_user).value_or( "ANONYMOUS USER" )

Success! Admittedly the maybe_name function is quite verbose, especially compared to Ruby's "lonely operator" ( &. ) or Rail's try method, which essentially achieves the same results. However, this was mostly done for demonstration purposes; generally one would use fmap in this case, which, unlike bind , works with blocks that return unwrapped values and automatically rewraps the result:

M.Maybe( nil ).fmap(& :name ).fmap(& :upcase ).value_or( "ANONYMOUS USER" ) "ANONYMOUS USER" M.Maybe(current_user).fmap(& :name ).fmap(& :upcase ).value_or( "ANONYMOUS USER" ) "JOHN MONADOE"

Other Useful Monads

At this point, you may still wonder if all of this effort is really worth it just to avoid a couple of nil checks. However, there are different "contexts" that have been modeled as monads, and everything we covered so far ( bind , fmap ) also applies to them.

The Result monad is similar to Maybe , but instead of None , it allows us to return an error object with additional information. For example, here's a sqrt function, which provides an exception-safe wrapper around Ruby's Math.sqrt :

require 'dry/monads/result' def sqrt (n) return M.Failure( "Value needs to be >= 0" ) if n < 0 M.Success(Math.sqrt(n)) end sqrt( 9 ) sqrt(- 1 )

If the input value n is outside the acceptable range, we return an error message wrapped in Failure ; otherwise, the result is wrapped in Success . Of course these values are composable too:

sqrt( 9 ).fmap { |n| n + 1 }.value_or( 0 ) sqrt(- 1 ).fmap { |n| n + 1 }.value_or( 0 )

The Result monad is used to great effect in the dry-transaction gem, which provides a business workflow DSL and is also available as an extension to dry-validation , a library for defining schemas and their accompanying validation rules.

Another useful monad is Try , which can be used for wrapping code that can potentially raise exceptions:

In case the user enters 0 (or just hits enter),

Try { 1 / 0 }.fmap { |n| n + 1 } Try::Error( ZeroDivisionError: divided by 0 )

Dividing one by zero would cause a ZeroDivisionError , but instead an instance of Try::Error is returned. With valid input, everything works as expected, and we'll receive Try::Value instead:

Try { 1 / 1 }.fmap { |n| n + 1 } Try { 1 / 1 }.bind { |n| n + 1 }

The possible result of a Try operation can be converted to a Result or Maybe value by using to_result or to_maybe .

Do Notation

Functional languages like Haskell and Scala provide a special syntax for working with monads, called "do notation." While it's not possible to mirror this exactly in Ruby, dry-monads provides a reasonable alternative.

The following example demonstrates how a function for transferring money could use do notation to sequence steps that can fail:

require 'dry/monads/result' require 'dry/monads/do/all' def transfer_money (params) sender = yield fetch_user(params[ :sender_id ]) receiver = yield fetch_user(params[ :receiver_id ]) amount = yield verify_amount(params[ :amount ]) receipt = yield transfer(sender, receiver, amount) Success([sender, receiver, receipt]) end def fetch_user (user_id) end def verify_amount (amount) end def transfer (sender, receiver, amount) end

In the above example, every step of the process returns a Result value and dry-monad s do notation uses a clever trick to extract the value from a monadic object in each method we're yield ing to. As soon as a Failure is encountered, the whole process short-circuits; otherwise, the unwrapped Success value gets returned.

Case Equality and Pattern Matching

Another nice feature of dry-monads is that it works with Ruby's case statement:

case maybe_name when Some( "JOHN MONADOE" ) then :john when Some( "LARRY LAMBDA" ) then :larry when Some( _ ) then :generic_user else :anonymous_user end

Additionally, dry-monads also provides pattern matching with the help of dry-matcher . Let's say we have a function called login , which authenticates a user and returns either Success(user) or Failure(error) . We can then use it in our controller like this:

require 'dry/matcher/result_matcher' include Dry::Matcher. for ( :login , with: Dry::Matcher::ResultMatcher) def login end login(user) do |m| m.success do |user| end m.failure do |err| end end

This turns error handling into a first-class construct since pattern matching will fail when one of the cases is missing. So if we remove the failure block from the above snippet, the following exception will be raised:

Dry: : Matcher: : NonExhaustiveMatchError: cases +failure+ not handled

Summary