Monads explained to my team — Part 4: The Maybe Monad

lomig

Lomig

Posted on July 7, 2022

Monads explained to my team — Part 4: The Maybe Monad

So, in the last article we created a monad, but there's room for improvement — let's pimp it a little then!

For reference, here is the code from the last article:

class Box
  private attr_reader :value

  def initialize(value)
    @value = value
  end

  def value! = value

  def map
    return self unless value

    Box.new(yield(value))
  end

  def flat_map(&block)
    map(&block).flatten
  end

  def flatten
    return self unless value

    value
  end
end
Enter fullscreen mode Exit fullscreen mode

  

Make it readable!

Let's rename the Box into something more abstract: it's full or empty, it wraps maybe something, maybe nothing — let's call it a Maybe object.

I'll also throw in a helper to ease initialization, and I'll override the default #inspect. (If you are not well versed into Ruby, it allows for the sweet examples below the class definition)

def Maybe(x) = Maybe.new(x)

class Maybe
  private attr_reader :value

  def initialize(value)
    @value = value
  end

  def value! = value

  def map
    return self unless value

    Maybe.new(yield(value))
  end

  def flat_map(&block)
    map(&block).flatten
  end

  def flatten
    return self unless value

    value
  end

  def to_s = "Maybe(#{value.inspect})"
  alias_method :inspect, :to_s
end

Maybe(nil).map { |x| x + 4 } #=> Maybe(nil)
Maybe(130).map { |x| x + 4 } #=> Maybe(134)
Enter fullscreen mode Exit fullscreen mode

Thanks to inheritance, we also can make the difference between a Maybe_but_in_fact_no and a Maybe_but_in_fact_yes nicer: when a Maybe has a value, it's a Some, and if not, it's a None.

def Maybe(x) = Maybe.new(x).coerce

class Maybe
  private attr_reader :value

  def initialize(value)
    @value = value
  end

  def value! = value

  def map(&block) = coerce.map(&block)

  def flat_map(&block) = map(&block).flatten

  def flatten
    return self unless value

    value
  end

  def coerce
    return None.new unless value

    Some.new(value)
  end
end

class Some < Maybe
  def initialize(value)
    raise ArgumentError if value.nil?

    super
  end

  def map = Maybe.new(yield(value)).coerce

  def to_s = "Some(#{value.inspect})"
  alias_method :inspect, :to_s
end

class None < Maybe
  def initialize; end

  def map = self

  def to_s = "None"
  alias_method :inspect, :to_s
end

Maybe(nil).map { |x| x + 4 } #=> None
Maybe(130).map { |x| x + 4 } #=> Some(134)
Enter fullscreen mode Exit fullscreen mode

  

Make it dry-like

The main gem providing monads to Ruby and Rails is called dry-monads. Of course, my implementation is quite naive compared to theirs, but let's rename our two mapping methods to follow their convention — even if some choices seem strange enough.

I'll change the code a last time as such:

  • rename #map as #fmap
  • rename #flat_map as #bind
  • add #none? and #some? methods
def Maybe(x) = Maybe.new(x).coerce

class Maybe
  private attr_reader :value

  def initialize(value)
    @value = value
  end

  def value! = value

  def fmap(&block) = coerce.fmap(&block)

  def bind(&block) = fmap(&block).flatten

  def flatten
    return self unless value

    value
  end

  def coerce
    return None.new unless value

    Some.new(value)
  end

  def none? = false
  def some? = false
end

class Some < Maybe
  def initialize(value)
    raise ArgumentError if value.nil?

    super
  end

  def fmap = Maybe.new(yield(value)).coerce

  def some? = true

  def to_s = "Some(#{value.inspect})"
  alias_method :inspect, :to_s
end

class None < Maybe
  def initialize; end

  def fmap = self

  def none? = true

  def to_s = "None"
  alias_method :inspect, :to_s
end

Maybe(nil).fmap { |x| x + 4 } #=> None
Maybe(130).fmap { |x| x + 4 } #=> Some(134)
Maybe(130).bind { |x| Maybe(x + 4) } #=> Some(134)
Enter fullscreen mode Exit fullscreen mode

  

As a Rubyist, why do I care?

Ruby handles itself quite nicely regarding the Maybe monad, thanks to the safe navigation operator (&.).
Still, imagine a method that would be used to show the weather for the county of the user. In this example, there are special rules to get the county from the user's zipcode. It could go like that:

def regional_weather(user_id)
  return :unavailable unless user_id

  user = User.find(user_id)
  zipcode = user&.addresses&.first&.zipcode
  return :unavailable unless zipcode

  county = county_from_zipcode(zipcode)
  return :unavailable unless county

  WeatherAPI.call(county) || :unavailable
end

def county_from_zipcode(zipcode)
  return zipcode unless zipcode.match?(/^\d+$/)

  zip = zipcode[..1].to_i

  # Paris!
  return 75 if [77, 78, 91, 92, 93, 94, 95].include?(zip)

  zip
end
Enter fullscreen mode Exit fullscreen mode

  
Let's use our Maybe monad:

def regional_weather(user_id)
  Maybe(user_id)
    .fmap { |user_id| User.find(user_id) }
    .fmap(&:addresses)
    .fmap(&:first)
    .fmap(&:zipcode)
    .fmap { |zipcode| county_from_zipcode(zipcode) }
    .fmap { |county| WeatherAPI.call(county) }
    .then { |result| return result.value! if result.some? }

  :unavailable
end

def county_from_zipcode(zipcode)
  return zipcode unless zipcode.match?(/^\d+$/)

  zip = zipcode[..1].to_i

  # Paris!
  return 75 if [77, 78, 91, 92, 93, 94, 95].include?(zip)

  zip
end
Enter fullscreen mode Exit fullscreen mode

Arguably a simpler way of handling nil values, isn't it?

  

Going further

Dry-Monads

I hope that I have been able to explain what is a monad in a simple way as well as explaining the usual meme-prone face-palming definition.

But moreover, with this last article I hope that I have been able to show how monads can help a developer maintaining an healthy and clear codebase.

dry-monads is a wonderful gem which implements lots of monad patterns and a DSL to use them in a very efficient way in an existing codebase.

There are also a ton of useful mapping methods built-in in their monads, for example to unwrap results, letting you do things like:

def regional_weather(user_id)
  Maybe(user_id)
    .fmap { |user_id| User.find(user_id) }
    .fmap { |user| user&.addresses&.first&.zipcode }
    .fmap { |zipcode| county_from_zipcode(zipcode) }
    .fmap { |county| WeatherAPI.call(county) }
    .value_or { :unavailable }
end
Enter fullscreen mode Exit fullscreen mode

That's brilliant!
  

Other Noteworthy Monads

You may find this video interesting when it comes to the Result monad which is used to handle failures and errors:

There is a wonderful article here by @baweaver — don't miss it.

Another common use case that you already heard of is Promise — but unfortunately JS Promises are not monads (it could have been, but unfortunately…)

A logger is also a classic use case of monads.

In any case, thanks for you attention, and merry functional programming!

💖 💪 🙅 🚩
lomig
Lomig

Posted on July 7, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related