DEV Community

Cover image for Mastering Monadical Syntax in Ruby: A Practical Guide to Functional Elegance
Pimp My Ruby
Pimp My Ruby

Posted on • Edited on

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

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!

Top comments (10)

Collapse
 
topofocus profile image
Hartmut B.

Hi,
although I too am fascinated by the abstraction of Dry::Monads, I share the suspicions of others about readability and introducing just more »syntax sugar«.
The monads-concept feels natural in rust, where its an integrated part of the language, but is a kind of artificial for a pure oop-language like ruby.
Rubyists must not fall into the same traps, perl developers did in the past, by just implementing anything form other languages just because its "Zeitgeist".

Its definitively not a substitute of if then else branching, however, in certain circumstances a smart enhancement .
Thanks for sharing!

Collapse
 
pimp_my_ruby profile image
Pimp My Ruby

Hi !

Returning a String, a Boolean, or an Integer do not give enough context. Using monad is a simple way to identify if it's a Success or a Failure, depending on your business logic.

There are other gems that help you to encapsulate the logic. The concept of encapsulating logic in order to give more context is not new.

The idea behind all that is to improve readability. I agree it's a new syntax. But once someone explained you / show you some example, it's very easily understandable.

Of course, you cannot just erase all the if in your code with using Monads, it is not THAT magic ahah. And for sure, I still use some return Failure() if xxx.

Let me know if you want to talk more about it, and maybe I can find some more complex codes example ?

Collapse
 
cherryramatis profile image
Cherry Ramatis

Amazing didactics and introduction to the monad theme!

On the Try example, why should I use this method instead of just rescuing? seems strange to me since it's already an exception instead of a monad

Collapse
 
pimp_my_ruby profile image
Pimp My Ruby • Edited

Actually, the Try monads is just a mapping rescue

Try[Error] { '123' }
<==>
begin
  Success('123')
catch Error => e
  Failure(e)
end
Enter fullscreen mode Exit fullscreen mode

So it is "just" sugar syntax

Collapse
 
cherryramatis profile image
Cherry Ramatis

Oh this is awesome actually, so it's possible to use this as a way to always have the provided monads

Collapse
 
buckled0 profile image
Daniel Buckle

I like the implementation of getting rid of if-else statements but are you worried about readability for devs who’ve never experienced this kind of pattern?

Collapse
 
pimp_my_ruby profile image
Pimp My Ruby

Hi Daniel, thanks for your feedback !

IMO, Monad concept is not so hard to understand but hard to master.

When we introduced dry-monads and monads concepts to our peers at wecasa, the majority of them never used FP before. But in fact, people loved that instantly.
As a friend said " return Success() is the new return true ".
Once we show you basic examples, you may be able to understand the basic (as showed in the article).

The bind / fmap / do notation concept is something we needed to explain deeper because they mean nothing for an OO programmer.

But when you have the fundamentals (Maybe, Success, Failure, bind) in fact you're good to go. Harder concepts will come when you code a bigger architecture, or when you need to interact with bridges.

Collapse
 
romeolove profile image
Eric

I'm a great fan of your content !

Keep up the great work

Collapse
 
pimp_my_ruby profile image
Pimp My Ruby

Hi Éric, that's very kind thanks <3

Collapse
 
a_chris profile image
Christian

I keep fighting with dry-monads due to weird yield behaviours that conflicts with rescue and other annoying stuff. So I built to-result, a wrapper over dry-monads that offer the ToResult method. It's like Try but on steroids but you can use all the dry-monads stuff at the same time 😎🚀