Mastering Monadical Syntax in Ruby: A Practical Guide to Functional Elegance

Pimp My Ruby - Nov 22 '23 - - Dev Community

My first encounter with Monadical writing was with the Haskell language. I immediately fell in love with the functional syntax. The challenge of the Haskell code I had to produce was simple: Never use "if-then-else." It was an incredible challenge.

My primary tool for avoiding "if-then-else" is Monadical writing.

So, first of all... What is a Monad?

A Monad is an abstraction that facilitates the management of sequences of complex operations by wrapping them in a specific context. This allows for clear and modular composition while maintaining centralized management of side effects.

Still sound vague? Let's take a very simple example:

# Maybe is defined in the gem "dry-monads". Will talk about it later :)
def find_user(user_id)
  Maybe(
    User.find_by(id: user_id)
  ).to_result(:user_not_found)
end
Enter fullscreen mode Exit fullscreen mode

There you go!

Our function uses the Maybe monad. Specifically, Maybe works as follows:

  1. It evaluates its content, here User.find(user_id).
  2. If the return value is not nil, it returns a Success result, encapsulating that return value.
  3. If the return value is nil, it returns a Failure result, encapsulating the error message :user_not_found.

The goal of Monadical writing in Ruby is, concretely, to have functions that all return a Success / Failure result.

This is straightforward, but Success is the result type called in case of success, and Failure in case of failure.

So, if we look back at find_user :

  • If we find the User, the function returns a Success object containing the user.
  • If we don't find the User, the function returns a Failure object containing the symbol :user_not_found.

Setting up Monads with Dry-monads

In this article, I will rely entirely on the implementation of monads by the dry-monads gem. You can find the documentation here.

If you've never heard of the dry-rb gem suite, I strongly encourage you to look into it. The philosophy of dry-rb is to guide you in writing simple, flexible, and maintainable code. It consists of 25 small gems, each bringing a simple yet incredibly powerful concept.

But let's get back to the topic at hand, dry-monads.

Add the gem to your Gemfile with bundle add dry-monads.

Consider the following class, which we will expand throughout the article:

require 'dry/monads'

class UpdateUserService
  include Dry::Monads[:maybe, :do, :try]

  def call(user_id:)
  end

  def find_user(user_id)
  end

  def update_user(user)
  end
end
Enter fullscreen mode Exit fullscreen mode

And there you go, we're ready to use dry-monads!


Let’s play with Monads!

First, we'll explore three types of monads, and then we'll discuss the benefits of using monads in your Rails project.

First of all, how to use Results?

Maybe is the monad we saw in the introduction.

Let's go back to the previous example and see how to interact with it!

def find_user(user_id)
  Maybe(User.find_by(id: user_id)).to_result(:user_not_found)
end
Enter fullscreen mode Exit fullscreen mode

We'll call our method and analyze its return value based on whether we find the user.

When we find the user, we get a result:

result = find_user(1)
result.success? # => true
result.failure? # => false
result.failure # => nil
result.value! # => #<User 0x000....>
Enter fullscreen mode Exit fullscreen mode

When we don't find the user:

result = find_user(0)
result.success? # => false
result.failure? # => true
result.failure # => :user_not_found
result.value! # => raises a Dry::Monads::UnwrapError
Enter fullscreen mode Exit fullscreen mode

We get a Result object, either of type Success or Failure, responding to several methods.

At this point, you should find this cool, but not quite enough to use it.

Wait and see 👀

Let's revisit the UpdateUserService class and implement our find_user method:

require 'dry/monads'

class UpdateUserService
  include Dry::Monads[:maybe, :do, :try]

  def call(user_id:)
    result = find_user(user_id)
  end

  def find_user(user_id)
    Maybe(User.find_by(id: user_id)).to_result(:user_not_found)
  end
end
Enter fullscreen mode Exit fullscreen mode

Now you might be thinking, the promise of "no more if-else" doesn't hold because a naive implementation would be:

def call(user_id:)
  result = find_user(user_id)
  return result.failure if result.failure?

  user = result.value!
  Success(user)
end
Enter fullscreen mode Exit fullscreen mode

But there, we're using the power of monadic writing in the worst way possible.

Since find_user returns a monad (Maybe), we can use the following syntax:

def call(user_id:)
  find_user(user_id).bind do |user|
    Success(user)
  end
end
Enter fullscreen mode Exit fullscreen mode

This writing does exactly the same thing, but without using any if statements!

  • If find_user returns a Failure, we stop execution, and the call function returns that Failure with the included error message.
  • If find_user returns a Success, we continue execution with user being what was encapsulated in the Success.

You can expose results of your monads with different notations:

In the previous example, we used .bind on our Maybe monad. In reality, there are several ways to exploit the result of a Maybe.

There is .fmap:

def call(user_id:)
  find_user(user_id).fmap do |user|
    user
  end
end
Enter fullscreen mode Exit fullscreen mode

In essence, it's the same as .bind, but the return value of the block is automatically a Success.

There is the "Do notation" with the use of yield:

def call(user_id:)
  user = yield find_user(user_id)
  Success(user)
end
Enter fullscreen mode Exit fullscreen mode

This notation is very concise and remains clear when chaining .bind / .fmap.

The bind, fmap, and do notations should be used as you see fit and depending on the context of use:

def bind_function
  function_a.bind do
    function_b.bind do
      function_c.fmap do
        'hello!'
      end
    end
  end
end

def do_notation_function
  yield function_a
  yield function_b
  value = yield function_c

  Success(value)
end
Enter fullscreen mode Exit fullscreen mode

In this use case, we prefer the second writing style, the Do notation.


You can rescue errors with monads!

In the monads I often use, there is also the Try monad. Specifically, Try allows you to encapsulate your begin rescue block to have a

monad as output.

Let's see a stupid but simple example:

def update_user(user)
  Try[ActiveModel::UnknownAttributeError] do
    user.update!(attribute_that_does_not_exist: true)
  end
end
Enter fullscreen mode Exit fullscreen mode

With the explanations given earlier, you should have an idea of the behavior of this piece of code.

Let's look at it together when we execute the update_user function:

  1. We execute the code in the block.
  2. If an exception is raised, we check if it belongs to the list of errors given as an argument to the Try monad.
  3. If the exception is known, we return a Failure with the error as content.
  4. If the exception is unknown, we raise the error.
  5. If no exception is raised, we return a Success with the return value of the block as content.

Similar to Maybe, Try has monadic functions like fmap, bind, the Do notation, and many other functions that I won't talk about in this article but are worth your interest.


One more thing

So far, we've seen monads independently. But in the definition given in the introduction, we talked about composition. Let's see how to compose with monads!

Let's say we want to integrate the following logic: After updating our user, we want to notify them of this update.

We really want to make sure that this notification is received.

For this, we will send a push notification, an SMS notification, and a notification to the company's Slack.

Fortunately for us, all these logics are already written in separate services, and they all return monads!

For our implementation, we want to call all the services, and if at least one fails, return a generic error.

A first implementation would be:

def notify_user(user)
  result_1 = PushNotificationService.call(user: user)
  result_2 = SmsNotifier.call(user: user)
  result_3 = SlackNotifier.call(user: user)

  [result_1, result_2, result_3].any? { |monad| monad.to_result.failure? }
end
Enter fullscreen mode Exit fullscreen mode

Feel that there's something odd about it?

We're missing out on a wonderful feature of monads: chaining operators!

It's exactly the same concept as when you do your ActiveRecord queries with .where.

We can use and to achieve exactly the same behavior:

def notify_user(user)
  PushNotificationService.call(user: user)
                         .and(SmsNotifier.call(user: user))
                         .and(SlackNotifier.call(user: user))
end
Enter fullscreen mode Exit fullscreen mode

We evaluate each of the 3 services, and if at least one returns a Failure, then that Failure will be returned.

PushNotificationService.call(user: user) # will return Success
                         .and(SmsNotifier.call(user: user)) # will return Failure
                         .and(SlackNotifier.call(user: user)) # will return Success

# So we can simplify by
# => Success.and(Failure).and(Success)
Enter fullscreen mode Exit fullscreen mode

In this case, since SmsNotifier will return a Failure, the entire function will return a Failure.

There are other chaining functions between your monads such as .or, .maybe, .filter, and more.

I invite you once again to check the documentation and experiment to learn more.


TL;DR

Monads are:

  • Abstractions that help reduce the conditional structure of your code.
  • Useful for standardizing communication between your classes.
  • Equipped with notations like .bind and .fmap to concisely compose your function.
  • Endowed with chaining operators like .and or .or to flexibly manage the composition of your monads.

Conclusion

Exploring Monadical writing in the context of Ruby offers a powerful perspective to enhance the elegance and simplicity of your code. By avoiding traditional conditional constructs, you adopt a more declarative and predictable approach to handling results and errors.

Monads, such as Maybe and Try, presented in this article, allow you to compose functions clearly and concisely while promoting standardized communication between your classes.

By integrating this approach into your Rails project, you can not only reduce code complexity but also promote a more modular and maintainable structure. In the end, monads emerge as essential tools for elegantly managing side effects, standardizing communication between different parts of your application, and creating robust and flexible processing pipelines.

I hope this article has inspired you to start writing monads in your Rails project and, most importantly, that the phrase:

-"A Monad is an abstraction that facilitates the management of sequences of complex operations by wrapping them in a specific context, allowing for clear and modular composition while maintaining centralized management of side effects."

holds no more secrets for you!

. . . . . . . . . . . . . . . . . . . .
Terabox Video Player