Libraries

Functional Ruby with `dry-monad`s

Illustrated by Sergey Konotopcev

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)
#=> None

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!
#=> Dry::Monads::UnwrapError: value! was called on None
maybe_name(current_user).value_or("ANONYMOUS USER")
#=> "ANONYMOUS USER"

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

user = OpenStruct.new(name: "john monadoe")

maybe_name(current_user)
#=> Some("JOHN MONADOE")
maybe_name(current_user).value!
#=> "JOHN MONADOE"
maybe_name(current_user).value_or("ANONYMOUS USER")
#=> "JOHN MONADOE"

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"
#=> Some("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)
#=> Success(3.0)

sqrt(-1)
#=> Failure("Value needs to be >= 0")

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)
#=> 4.0
sqrt(-1).fmap { |n| n + 1 }.value_or(0)
#=> 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::Value(2)
Try { 1 / 1 }.bind { |n| n + 1 }
#=>2

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)
  # Success(user) or Failure(error)
end

def verify_amount(amount)
  # Success(amount) or Failure(error)
end

def transfer(sender, receiver, amount)
  # Success(receipt) or Failure(error)
end

In the above example, every step of the process returns a Result value and dry-monads do notation uses a clever trick to extract the value from a monadic object in each method we're yielding 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
  # Success(user) or Failure(error)
end

login(user) do |m|
  m.success do |user|
    # Success case, e.g. redirect to profile page
  end

  m.failure do |err|
    # Error case, e.g. setting flash to error message
  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

Hopefully this article demystified monads a little bit and provided some ideas and insights into how the dry-monads gem can be used to clean up your application code by turning concepts like failure (Result), absence of value (Maybe), or computations that can fail (Try) into first-class constructs that follow a common pattern and can be easily composed.

Contributors

Michael Kohl

Author

Michael's love affair with Ruby started around 2003. He also enjoys writing and speaking about the language and co-organizes Bangkok.rb and RubyConf Thailand.

Sergey Konotopcev

Illustrator

.