A concise definition from Wikipedia:
In functional programming, a monad is a design pattern that defines how functions, operations, inputs, and outputs can be used together to build generic types, with the following organization:
a. Define a data type, and how values of that data type are combined. b. Create functions that use the data type, and compose them together into operations, following the rules defined in the first step.
A monad may encapsulate values of a particular data type, creating a new type associated with a specific additional computation, typically to handle special cases of the type.
There are additional resources at the bottom of this document that can help one grok the concept.
I have chosen to write the examples in this document using the excellent dry-monads gem.
The Maybe
monad is used when a series of computations could return nil
at any point.
In order to get the name of cycle for the current employee related to a user, one would usually write:
user.current_employee.cycle.name || "No cycle"
If any of the objects in the chain is nil
, we will get a NoMethodError
.
So, as an alternative, we can use the Maybe monad:
cycle_name = Maybe(user).fmap(&:current_employee).fmap(&:cycle).fmap(&:name)
cycle_name.value_or("No cycle")
Maybe(user)
returns Some(user)
if user
is not nil
, otherwise None
. Calling fmap(&:current_employee)
returns Some(employee)
for the first case (user
not nil
), but returns None
for the second. fmap(&:cycle)
and fmap(&:name)
work similarly. Using value_or
, we can extract the final value and provide a default value as an argument, which is used for the user
is nil
case.
Suppose we want to set the company for a user based on the name of a company. Here's what one can usually do:
user = User.find_by(id: user_id)
company = User.find_by(name: company_name)
if user && company
user.update(company_id: company.id)
end
When using monads, we can use the bind
method in such cases:
def find_user(user_id)
Maybe(User.find_by(id: user_id))
end
def find_company(company_name)
Maybe(Company.find_by(name: company_name))
end
find_user(params[:user_id]).bind do |user|
find_company(params[:company_name]).bind do |company|
Some(user.update(company_id: company.id)
end
end
bind
provides unwrapped value to the provided block if it's a Some
(returns itself if it's a None
) and returns a wrapped value (Some
or None
).
fmap
is similar to bind except for the fact that it works with blocks that return unwrapped values.
When we use Maybe
and get a None
, we cannot know why a None
was returned, that is at what point did we get a nil value. Thus, it can be preferable to use Result
monad instead, which has Success
and Failure
cases (instead of Some
and None
). The Failure
object can hold information about the reason for failure.
def find_user(user_id)
user = User.find_by(id: user_id)
user ? Success(user) : Failure(:user_not_found)
end
def find_company(company_name)
company = Company.find_by(name: company_name)
company ? Success(company) : Failure(:company_not_found)
end
Using bind
to compose:
find_user(params[:user_id]).bind do |user|
find_company(params[:company_name]).bind |company|
Success(user.update(company_id: company.id))
end
end
In order to get the value a Maybe
monad is wrapped around, one can call value!
. If it's nil, you get an error.
Dry::Monads::Maybe('monads').fmap(&:capitalize).value! => 'Monads'
Dry::Monads::Maybe(nil).fmap(&:capitalize).value! => Dry::Monads::UnwrapError (value! was called on None)
The same applies to Result
monad
Dry::Monads::Success('monads').fmap(&:capitalize).value! => 'Monads'
Dry::Monads::Failure('monads').fmap(&:capitalize).value! => Dry::Monads::UnwrapError (value! was called on Failure("monads"))
It can be cumbersome to use monads in the way described above. Also, it can make the code a bit harder to read. But, worry not, there is an elegant solution to that called the "Do notation." dry-monads documentation explains:
What
Do
does is passing an unwrapping block to certain methods. The block tries to extract the underlying value from a monadic object and either short-circuits the execution (in case of a failure) or returns the unwrapped value back.
Here's an example of using it:
class UpdateCompany
include Dry::Monads::Result::Mixin
include Dry::Monads::Do.for(:call)
def call(options)
user = yield find_user(options[:user_id])
company = yield find_company(options[:company_name])
Success(user)
end
def find_user(user_id)
user = User.find_by(id: user_id)
user ? Success(user) : Failure(:user_not_found)
end
def find_company(company_name)
company = Company.find_by(name: company_name)
company ? Success(company) : Failure(:company_not_found)
end
end
- Tom Stuart's deep dive talk. He implements monads himself instead of using a gem, which is very helpful when trying to understand how monads work. Refactoring Ruby with Monads, and his blog post
- Elegant visual illustration of what monads are and how they work. Functors, Applicatives, And Monads In Pictures
- Translation from Haskell to JavaScript of selected portions of the best introduction to monads I’ve ever read
- Mostly Adequate Guide to Functional Programming