Introduction
dry-monads is a set of common monads for Ruby. Monads provide an elegant way of handling errors, exceptions and chaining functions so that the code is much more understandable and has all the error handling, without all the if
s and else
s. The gem was inspired by the Kleisli gem.
What is a monad, anyway? Simply, a monoid in the category of endofunctors. The term comes from category theory and some believe monads are tough to understand or explain. It's hard to say why people think so because you certainly don't need to know category theory for using them, just like you don't need it for, say, using functions.
Moreover, the best way to develop intuition about monads is looking at examples rather than learning theories.
How to use it?
Let's say you have code like this
user = User.find_by(id: params[:id])
if user
address = user.address
end
if address
city = address.city
end
if city
state = city.state
end
if state
state_name = state.name
end
user_state = state_name || "No state"
Writing code in this style is tedious and error-prone. There were created several "cutting-corners" means to work around this issue. The first is ActiveSupport's .try
which is a plain global monkey patch on NilClass
and Object
. Another solution is using the Safe Navigation Operator &.
introduced in Ruby 2.3 which is a bit better because this is a language feature rather than an opinionated runtime environment pollution. However, some people think these solutions are hacks and the problem reveals a missing abstraction. What kind of abstraction?
When all objects from the chain of objects are there we could have this instead:
state_name = User.find_by(id: params[:id]).address.city.state.name
user_state = state_name || "No state"
By using the Maybe
monad you can preserve the structure of this code at a cost of introducing a notion of nil
-able result:
state_name = Maybe(
User.find_by(id: params[:id])
).maybe(&:address).maybe(&:city).maybe(&:state).maybe(&:name)
user_state = state_name.value_or("No state")
Maybe(...)
wraps the first value and returns a monadic value which either can be a Some(user)
or None
if user
is nil
. maybe(&:address)
transforms Some(user)
to Some(address)
but leaves None
intact. To get the final value you can use value_or
which is a safe way to unwrap a nil
-able value. In other words, once you've used Maybe
you cannot hit nil
with a missing method. This is remarkable because even &.
doesn't save you from omitting || "No state"
at the end of the computation. Basically, that's what they call "Type Safety".
A more expanded example is based on composing different monadic values. Suppose, we have a user and address, both can be nil
, and we want to associate the address with the user:
user = User.find_by(id: params[:user_id])
address = Address.find_by(id: params[:address_id])
if user && address
user.update(address_id: address.id)
end
Again, this implies direct work with nil
-able values which may end up with errors. A monad-way would be using another method, bind
:
maybe_user = Maybe(User.find_by(id: params[:user_id]))
maybe_user.bind do |user|
maybe_address = Maybe(Address.find_by(id: params[:address_id]))
maybe_address.bind do |address|
user.update(address_id: address.id)
end
end
One can say this code is opaque compared to the previous example but keep in mind that in real code it often happens to call methods returning Maybe
values. In this case, it might look like this:
find_user(params[:user_id]).bind do |user|
find_address(params[:address_id]).bind do |address|
Some(user.update(address_id: address.id))
end
end
Finally, since 1.0, dry-monads has support for do
notation which simplifies this code even more, making it almost regular yet nil
-safe:
user = yield find_user(params[:user_id])
address = yield find_address(params[:address_id])
Some(user.update(address_id: address.id))
Another widely spread monad is Result
(also known as Either
) that serves a similar purpose. A notable downside of Maybe
is plain None
which carries no information about where this value was produced. Result
solves exactly this problem by having two constructors for Success
and Failure
cases:
def find_user(user_id)
user = User.find_by(id: user_id)
if user
Success(user)
else
Failure(:user_not_found)
end
end
def find_address(address_id)
address = Address.find_by(id: address_id)
if address
Success(address)
else
Failure(:address_not_found)
end
end
You can compose find_user
and find_address
with bind
:
find_user(params[:user_id]).bind do |user|
find_address(params[:address_id]).bind |address|
Success(user.update(address_id: address.id))
end
end
The inner block can be simplified with fmap
:
find_user(params[:user_id]).bind do |user|
find_address(params[:address_id]).fmap |address|
user.update(address_id: address.id)
end
end
Or, again, the same code with do
:
user = yield find_user(params[:user_id])
address = yield find_address(params[:address_id])
Success(user.update(address_id: address.id))
The result of this piece of code can be one of Success(user)
, Failure(:user_not_found)
, or Failure(:address_not_found)
. This style of programming is called "Railway Oriented Programming" and you can check out dry-transaction and watch a nice video on the subject. Also, see dry-matcher for an example of how to use monads for controlling the flow of code with a result.
A word of warning
Before do
came around here was a warning about over-using monads, turned out with do
notation code does not differ much from regular Ruby code. Just don't wrap everything with Maybe
, come up with conventions.
If you're interested in functional programming in general, consider learning other languages such as Haskell, Scala, OCaml, this will make you a better programmer no matter what programming language you use on a daily basis. And if not earlier then maybe after that, dry-monads will become another instrument in your Ruby toolbox :)
Credits
dry-monads is inspired by Josep M. Bach’s Kleisli gem and its usage by dry-transaction and dry-types.