Monads — To use or not to use, that’s the question

Yifeng Hou
abetterconsultancy
Published in
5 min readMay 10, 2021

Why should you care about Monads?

Before we dive into Monads, I want to share my thoughts about learning in general. To me, learning feels like window shopping most of the time. In particular, 80% of the things we see in-store or learn about online are not useful. The real challenge is that we won’t know what are 80% useless things until we think in retrospect. Therefore, it’s better to know something and not need it than to need something but don’t know it.

In programming, Monads kind of sits on the border between 80% useless and 20% useful. Because most of the business use cases can be handled without Monads, but the capabilities provided by Monads just sound so attractive. Quote from Wikipedia:

In functional programming, a monad is an abstraction that allows structuring programs generically. Supporting languages may use monads to abstract away boilerplate code needed by the program logic.

As programmers, of course, we want to avoid boilerplate code. But how exactly? If it takes 7 hours to learn about Monads properly and 1 hour to implement the solution, compared to 5 hours to implement with some boilerplate code. Which would you choose? Of course, we go for the latter. Then we can go home early and still have time to spare.

Therefore, the second point I want to share about learning, besides the 80–20 rule, is the importance of examining the tradeoffs. In other words, you probably need to find a compelling use case that makes sense for you to invest time to study Monads. That’s why I want to describe the compelling use case that drove me to study Monads.

When did I have to use Monads?

I still remember vividly the day I made up my mind to study Monads. It was a pleasant balmy morning when I was struggling internally to generate a report in a way that I won’t hate myself in the future. The report is a summary of multiple data sources. Data could be missing from any of the data sources. Furthermore, a series of calculations need to be applied to these data sources. Exceptions could be thrown at any step in the calculation. To top up the complexity, as if it wasn’t challenging enough when the report fails to generate due to missing data, a list of the missing data types should be generated.

I could have fulfilled this request in a normal boilerplate approach which introduces a lot of try and catch. But the implementation will become unreadable very quickly as my core logic will be buried in try and catch. Moreover, the calculations consist of multiple costly steps. If the first step in the calculation already failed, the subsequent steps should be smart enough to stop executing in the event of an earlier calculation failure.

That’s when I took a deep breath and realized that I finally found my compelling use case to learn monad.

Window shopping Monads: Failure

Failure is an ominous name to give a data class but after some “window shopping” on Monads, the Failure Monad showcased by Martin McBride in his article Monads in Python best suits our needs.

Here’s the implementation for Failure Monad in his article

class Failure():        def __init__(self, value, failed=False):
self.value = value
self.failed = failed
def get(self):
return self.value
def is_failed(self):
return self.failed
def __str__(self):
return ' '.join([str(self.value), str(self.failed)])

def bind(self, f):
if self.failed:
return self
try:
x = f(self.get())
return Failure(x)
except:
return Failure(None, True)

If you want to understand more about the Failure Monad, feel free to read Martin’s article first. It’s a very comprehensible article to implement Monads in Python.

The Failure Monad uses a failed flag to determine whether to execute the function on the data, which is very close to achieving what we want to generate the report. We just need a few more tweaks.

Bend that Monad to our wills

Before we modify the Failure Monads, let’s revisit our requirements for generating the report.

  • Skip subsequent calculations if previous steps fail
  • Get all missing data types that cause the calculation to fail

The skipping part is handled by the failed flag, we just need to collect the missing data types when calculations fail. Here’s the modified version of Failure Monad.

class Failure():    def __init__(self, value, data_types, errors=[]):
self.value = value
self.data_types = data_types
self.errors = errors
def get(self):
return self.value
def get_errors(self):
return self.errors
def is_failed(self):
return len(self.errors) > 0
def __str__(self):
return ' '.join([str(self.value), str(self.is_failed())])

def bind(self, f):
if self.is_failed():
return self
try:
x = f(self.get())
return Failure(x, self.data_types)
except:
return Failure(None, None, self.data_types)

The modified Failure Monad now carries the data_types that cause the calculation exception. You might wonder what happens if multiple data sources fail in the calculation. How are we getting a list of all the missing data types? We will fulfill this purpose by creating a FailureList that takes a list of Failure Monads and combine their data context.

class FailureList():
def __init__(self, *args):
self.value = args
self.data_types = flat_map([v.data_types for v in args])
self.errors = flat_map([v.get_errors() for v in args])

def get(self):
return self.value

def get_errors(self):
return self.errors

def is_failed(self):
return len(self.errors) > 0

def __str__(self):
return ' '.join([str(self.value), str(self.is_failed())])

def bind(self, f):
if self.is_failed():
return self
try:
x = f(*[v.value for v in self.value])
return Failure(x, self.data_types)
except:
return Failure(None, None, self.data_types)

The FailureList takes a list of Failure Monads. If all Failure Monads have not failed, the FailureList will unwrap the value from those Monads and execute the subsequent function with all the values in the order they passed in. If any of the Monads already failed, the FailureList will skip subsequent calculations just like Failure Monad. Moreover, the FailureList will carry the missing data types from all Failure Monads. Let’s look at some quick examples.

Happy Case, both Monads are not failed:

good_apple = Failure(1, ['apples'])
good_orange = Failure(2, ['oranges'])
def get_fruit_report(app, ora):
return f'apples {app} + oranges {ora} = {app + ora}'
report = FailureList(bad_apple, good_orange).bind(get_fruit_report)

print(report.get())
# apples 1 + oranges 2 = 3
print(report.is_failed())
# False
print(report.get_errors())
# []

Unhappy Case 1, one of the Monads is failed:

bad_apple = Failure(None, None, errors=['apples'])
good_orange = Failure(2, ['oranges'])
def get_fruit_report(app, ora):
return f'apples {app} + oranges {ora} = {app + ora}'
report = FailureList(bad_apple, good_orange).bind(get_fruit_report)

print(report.get())
# [None, 2]
print(report.is_failed())
# True
print(report.get_errors())
# ['apples']

Unhappy Case 2, both Monads are failed:

bad_apple = Failure(None, None, errors=['apples'])
bad_orange = Failure(None, None, errors=['oranges'])
def get_fruit_report(app, ora):
return f'apples {app} + oranges {ora} = {app + ora}'
report = FailureList(bad_apple, bad_orange).bind(get_fruit_report)

print(report.get())
# [None, None]
print(report.is_failed())
# True
print(report.get_errors())
# ['apples', 'oranges']

Now the combination of Failure and FailureList helps to fulfill the requirements for report generation without too much boilerplate code. There’s still room for improvement as the Failure and FailureList have duplicated methods that can be extracted to a common base class. But what we have so far should demonstrate the thought process of borrowing the Monad pattern to reduce boilerplate code.

Where do we go from here?

If you read until here, you probably are very serious in your desire learn more about Monads. Below is a list of the key resources I used in my research for this article, for reference.

Happy Coding :) Maybe with a cup of coffee~ ☕☕☕

--

--

Yifeng Hou
abetterconsultancy

AI Solutions Engineer, Technology enthusiast, Business Sustainability Advocate