DEV Community

TH Lim
TH Lim

Posted on • Updated on

Looking at A Monad Through An Example

Though many articles were written about Monad, this article shows how a Monad is used in practice with an example and explains why a Monad is needed. Let's begin right away.

The code snippets in this article are using Scala 3 syntax

This article uses these 2 functions as an example,

def div(a: Int, b: Int) = ???           // A
def add(a: Int, b: Int): Int = a + b    // B
Enter fullscreen mode Exit fullscreen mode

Function (A) divides the first integer by the second integer. Function (B) adds 2 integers together. These 2 functions are pretty straightforward. Unlike function (B), a total function, function (A) cannot compute when the second parameter is 0. If the parameter types are Float, the function will return Infinity, but this is Int, throwing a java.lang.ArithmeticException exception with the message / by zero. Function (A) must find a way to let its caller know it cannot compute if the second parameter is zero. There are a few ways to handle the error and fortunately, there is a clear way to do this.

Function (A) actual implementation is determined by how the value and error are handled in each scenario.

Returning An Error Code

For some cases returning an error code is the simplest solution. When the function fails, it returns an error code to indicate failure. Case in point, for function (A), if parameter b is zero, it fails and returns -1 given the following implementation,

def  div(a: Int, b: Int): Int = if (b == 0) -1 else a / b  // Bad implementation
Enter fullscreen mode Exit fullscreen mode

This is a bad implementation because -1 is a legit value for div(-8, 8). There is no integer the function can return to indicate an error. Therefore, this method is not viable.

A Error And Result Pair

Next, we split the error and result into a pair,

def  div(a: Int, b: Int): (String, Int) = if (b == 0) ("/ by zero", 0) else (null, a / b)

val (error1, value1) = div(10, 2)   // (null, 5)

if (error1 == null) {
    println(s"The result of 10 / 2 is $value1")
} else {
    println("Cannot be divided by zero")

val (error2, value2) = div(10, 0)   // ("/ by zero", 0)

// repeat the if-else block with error1 replace by error2
Enter fullscreen mode Exit fullscreen mode

The second part of the extraction logic conflicts with DRY principle. Yes, we can refactor the if-else block into a function,

def extract[A](result: (String, Int), ifOk: A => Unit, ifNOK: A => Unit) =
    if (result._1 == null) {
        println(s"The result of 10 / 2 is ${result._2}")
    } else {
        println("Cannot be divided by zero")
Enter fullscreen mode Exit fullscreen mode

Unfortunately, extract (...) is so restrictive. This will cause writing extract(...) to suit simple needs in many ways. Also, what if I want to compose the result of the function div(...) with add(...) i.e. add(1, div(10, 0)). The permutation will explode. This method is workable but soon it becomes a maintenance nightmare once the requirements get complicated.

Throwing An Exception

In Scala, all exceptions are unchecked unlike Java, where exceptions are split into checked Exception and unchecked RuntimeException. In Java, functions that throw checked exceptions are enclosed in a try-catch block. On the other hand, Scala developers use the try-catch block to catch the exception they want to catch.

In retrospect, throwing an exception seems to be the way forward. It is easy to implement the function (A) using the exception,

def  div(a: Int, b: Int): Int = a / b
Enter fullscreen mode Exit fullscreen mode

The responsibility lies with the caller to catch the exception but the developer does not know if a function can throw an exception when certain parameteric values are met. Consequently, any function that the application uses can cause the application to be unusable or unstable at best if the exception is not caught in its place. This makes the job of the developers very unpleasant. Worse, we are back to writing our code the Java-style with try-catch or try-catch-finally blocks everywhere blindfolded1.

The Better Answer, Use An Effect

The effect in this context is a container or more specifically, a container with capabilities. The effect is not a side effect. A simple container like Option is a list with the maximum capacity of 1 element or empty. With the Option effect the function can let its caller know its return status. The function will return Some or None to indicate success or failure. Function (A) implementation and usage would look like this

def  div(a: Int, b: Int): Option[Int] = if (b == 0) None else Some(a / b)

val result1: Option[Int] = div(10, 2)   // Some(5)
val result2: Option[Int] = div(10, 0)   // None

// print the result
def extract(result: Option[Int]): Unit = result match {
  case Some(x) => println(s"The result is $x")
  case None    => println("Cannot be divided by 0")

extract(result1)    // The result is 5
extract(result2)    // Cannot be divided by 0
Enter fullscreen mode Exit fullscreen mode

Please do not eagerly extract values from the effect unless this is the final call.

Let's say, we have a function to add 10 to the result if the result is even otherwise cancel the whole calculation. None represents the cancelation in the composed function.

It is not recommended to write addIfEven(10, div(10, 2).get) because when div(...) returns a None instead of a Some[Int], invoking a get on None would cause an exception.

Using Option inside the function i.e. addIfEven(a: Int, b: Option[Int]): Option[Int] is bad in this case because it makes the code difficult to work with the values inside Option.

Instead, define as addIfEven(a: Int, b: Int): Option[Int] and write the code the following way,

val result3: Option[Int] = for {
  x <- div(10, 2)
  y <- addIfEven(10, x)
} yield y

extract(result3)    // The result is 15

val result4: Option[Int] = for {
  x <- div(10, 0)
  y <- addIfEven(10, x)
} yield y

extract(result4)    // Cannot be divided by zero
Enter fullscreen mode Exit fullscreen mode

It is not necessarily for the developer to check if the call to div(...) is a success or failure before moving on to the next function. The result will be fed to the next function and continue to do so as long as there are functions to call, the final result from the for-comprehension loop will return Some of a value or None.

Had the function (B) returned the result of type Option[Int] instead of Int, we can rewrite the for-comprehension loop as,

def add(a: Int, b: Int): Option[Int] = Some(a + b)

val result5: Option[Int] = for {
  x <- div(10, 2)
  y <- add(10, x)
} yield y
Enter fullscreen mode Exit fullscreen mode

What if we want the function to provide the error message instead? We can use Either[String, Int].

def div(a: Int, b: Int): Either[String, Int] = 
  if (b == 0) Left("/ by zero") else Right(a / b)

def extract(result: Either[String, Int]): Unit = result match {
  case Right(x)  => println(s"The result is $x")
  case Left(err) => println(s"Error: $err")

val result6: Either[String, Int] = for {
  x <- div(10, 2)
} yield add(10, x)

extract(result6)    // The result is 5

val result7: Either[String, Int] = for {
  x <- div(10, 0)
} yield add(10, x)  

extract(result7)    // Error: / by zero
Enter fullscreen mode Exit fullscreen mode

Use exception to simply div(...),

def div(a: Int, b: Int): Either[String, Int] = 
  try {
    Right(a / b)
  } catch {case e: ArithmeticException => Left(e.getMessage)}
Enter fullscreen mode Exit fullscreen mode

As we discover later on, we can use other data types like Try from the standard Scala library or IO from a 3rd party library Cats Effect.
As mentioned before, an effect is a container with capabilities: -

  1. Option provides a value or no value (empty) capability.
  2. List provides a list of values or no value (empty) capability.
  3. Try, like the try-catch block, catches any exception thrown within it.
  4. IO is an IO Monad which has many capabilities which include handling side-effects, error handling, parallel computation, and many more.

And The Point Is...

Using effect is a good approach to resolve this issue. But, what does this have to do with Monads? This is one of many ways using Monads to simplfy branching between actual and unexpected (bad) events without deeply nested if-else-then branches in the flow. The same monadic approach can be used to solve other issues in a similar fashion like how bad parameter or input is handled. However, this topic requires more reading and practice before it can be truly useful. We have to start somewhere. The payoff is making the code highly manageable as more code is added to tackle new requirements. Thank you for reading.

For-Comprehension And Typeclass (Optional)

A Monad is a typeclass2 that has a few functions. In the interest of this article, the focus is on the map and flatMap functions. map is inherited from the Functor. Strictly speaking, a Monad is a subclass of Applicative which in turn a subclass of Functor.

In Scala, the for-comprehension loop is a synatic sugar for a series of flatMap and mape.g.,

val result8: Option[Int] = for {
  x <- div(10, 2)
  y <- Option(x - 10)
} yield add(10, y)

// loosely converted to

val result8: Option[Int] = 
  div(10, 2)
    .flatMap(x => Option(x - 10)
    .map(y => add(10, y)))
Enter fullscreen mode Exit fullscreen mode

Classes like Option, List, and Either can work right out of the box with for-comprehension because these classes have map and flatMap methods defined. If a random class MyBox without these 2 methods defined, it would not work. The developer could add these methods to MyBox if the developer owns the source. If he does not, then he has to use adhoc polymorphism a.k.a typeclassing which is very useful for extending the class capabilities. Please refer to here for the MyBox Monad typeclass implemention and example.

Classes must conforms to the Monad Law to be a Monad. For example, Option, List, and Either are monads because they passed the Monad Law test. Classes like Set and Try are not because they failed the test even though they have map and flatMap methods defined.

  1. Scala 3 is experimenting with the selective checked exception capture using the CanThrow capability. 

  2. Typeclass is like Java interface. However, It is imperative to understand how typeclass functions. Please refer to for an introduction. 

Top comments (0)