The Problem:
- To find square of a number and add one.
- Add logs to the see how the result is formed.
- Should be able to add more functions easily like multiplyThree.
- Functions defined should be composable in any order (we can multiplyThree before addOne or vice versa).
We will solve this problem using a monad design pattern in a step by step thought process.
Table of Contents
Disclaimer: scala
code written here are just for the sake of demonstrating the concept of monads.
Who should read: beginner software engineer who want to know what monad is, without going into category theory.
Prerequisite: little bit of scala
(not required, as you can use chatgpt for basic syntax)
How to read: This article contains 4 thoughts (0 to 3). Read through each thought by thought. At the end of each thought there is a thought (questions in mind) that is left behind. Try to come up with a solution for the question presented at end of each thought before moving to next one.
Let's try to find the solution:
Thought 0
We can start by implementing two functions, addOne
and square
, for the first task. But how do we implement logging?
One approach is to explicitly add logs in the main function, as follows, detailing how the final result is calculated. However, as we add more functions and compose them in different orders, manually managing and printing logs becomes increasingly difficult.
Code 0:
1 object MonadsExample {
2 def main(args: Array[String]): Unit = {
3 println(addOne(square(2)))
4 println("Squared 2 to get 4")
5 println("Added 1 to 4 to get 5")
6 }
7 def square(x: Int): Int = {
8 x * x
9 }
10 def addOne(x: Int): Int = {
11 x + 1
12 }
13 }
Ouput 0:
1 5
2 Squared 2 to get 4
3 Added 1 to 4 to get 5
Is there a better way to handle this?
Thought 1
It seems we can declare a wrapper type (in this case, a case class NumberWithLogs
) to hold both the result and the logs together. We can then modify the return type of the square function to use this wrapper type (NumberWithLogs
). This way, our return value contains not only the result but also the associated log.
But wait—there’s a problem! How can we pass the result from the square
function to the addOne
function? To address this, we can change the input type of addOne
to accept NumberWithLogs
. Great—it works now!
Code 1:
1 object MonadsExample {
2 def main(args: Array[String]): Unit = {
3 print(addOne(square(2)))
4 }
5 case class NumberWithLogs (
6 result: Int,
7 logs: Seq[String]
8 )
9 def square(x: Int): NumberWithLogs = {
10 NumberWithLogs(
11 x * x,
12 Seq(s"Squared ${x} to get ${x * x}")
13 )
14 }
15 def addOne(x: NumberWithLogs): NumberWithLogs = {
16 NumberWithLogs(
17 x.result + 1,
18 x.logs :+ s"Added 1 to ${x.result} to get ${x.result + 1}"
19 )
20 }
21 }
Output 1:
1 NumberWithLogs(5,List(Squared 2 to get 4, Added 1 to 4 to get 5))
However, what if we want to add one to the square of the square of 2? Since the square function takes an unwrapped value (i.e., Int) as its argument and returns a NumberWithLogs, we can only use it the first time. Is there a way to handle this?
Thought 2
We can modify the input type of the square
function to accept NumberWithLogs
. This allows us to chain two square functions together.
But wait—how do we pass input to the square function for the first time? To address this, we can define a new function, wrapWithLogs
, which takes an unwrapped value (i.e., Int) and converts it into a NumberWithLogs
. Yes, this works as expected now!
Code 2
1 object MonadsExample {
2 def main(args: Array[String]): Unit = {
3 print(addOne(square(wrapWithLogs(2))))
4 }
5 def wrapWithLogs (x: Int): NumberWithLogs = {
6 NumberWithLogs(x, Seq.empty)
7 }
8 case class NumberWithLogs (
9 result: Int,
10 logs: Seq[String]
11 )
12 def square(x: NumberWithLogs): NumberWithLogs = {
13 NumberWithLogs(
14 x.result * x.result,
15 x.logs :+ s"Squared ${x.result} to get ${x.result * x.result}"
16 )
17 }
18 def addOne(x: NumberWithLogs): NumberWithLogs = {
19 NumberWithLogs(
20 x.result + 1,
21 x.logs :+ s"Added 1 to ${x.result} to get ${x.result + 1}"
22 )
23 }
24 }
Output 2
1 NumberWithLogs(5,List(Squared 2 to get 4, Added 1 to 4 to get 5))
However, did you notice that every function we have now needs to do the extra work of appending logs? It’s less than ideal for a function like square
to handle the responsibility of appending logs. Can we do better?
Thought 3
Instead of having the individual functions (e.g., addOne) take responsibility for appending logs, how about introducing another function, runWithLogs? This function would take a NumberWithLogs instance and a transformation function (e.g., addOne, square, etc.).
The runWithLogs function can apply the transformation function to the value inside the NumberWithLogs instance (by unwrapping it). Now, we also have access to both the logs generated by the transformation and the previous logs (supplied by the input NumberWithLogs). We can simply append these logs and return the updated result.
Code 3
1 object MonadsExample {
2 def main(args: Array[String]): Unit = {
3 print(
4 runWithLogs(
5 runWithLogs(wrapWithLogs(2), square),
6 addOne
7 )
8 )
9 }
10 def runWithLogs(input: NumberWithLogs, transform: Int => NumberWithLogs):NumberWithLogs = {
11 val newNumberWithLogs = transform(input.result)
12 NumberWithLogs(
13 newNumberWithLogs.result,
14 input.logs ++ newNumberWithLogs.logs
15 )
16 }
17 def wrapWithLogs (x: Int): NumberWithLogs = {
18 NumberWithLogs(x, Seq.empty)
19 }
20 case class NumberWithLogs (
21 result: Int,
22 logs: Seq[String]
23 )
24 def square(x: Int): NumberWithLogs = {
25 NumberWithLogs(
26 x * x,
27 Seq(s"Squared ${x} to get ${x * x}")
28 )
29 }
30 def addOne(x: Int): NumberWithLogs = {
31 NumberWithLogs(
32 x + 1,
33 Seq(s"Added 1 to ${x} to get ${x + 1}")
34 )
35 }
36 }
Output 3
1 NumberWithLogs(5,List(Squared 2 to get 4, Added 1 to 4 to get 5))
This way, all the log-appending logic is centralized in a single function—runWithLogs
.
How can we extend this ?
- To add a new transformer like
multiplyByThree
, just add a new transformation function.
1 def multiplyByThree(x: Int): NumberWithLogs = {
2 NumberWithLogs(
3 x * 3,
4 Seq(s"Multiplied 3 to ${x} to get ${x * 3}")
5 )
6 }
- To chain the functions in any order, use following code.
1 print(
2 runWithLogs(
3 runWithLogs(wrapWithLogs(2), addOne),
4 square
5 )
6 )
What is a monad ?
Monad in context of functional programming is the pattern that we just implemented. Just to recap it had following properties.
- Wrapper Type: NumberWithLogs
- Wrap Function: wrapWithLogs
- Run Function: runWithLogs
Visualization

- In the “normal world,” we start with a value like 2. To enter the “monad world,” we wrap this value using NumberWithLogs.
- The runWithLogs function takes this wrapper type (NumberWithLogs containing the value 2) and a transformation function. It unwraps the NumberWithLogs, applies the given function to the unwrapped value (in this case, 2), and then rewraps the result along with updated logs.
- We can chain transformations by using another runWithLogs, which takes the result of the previous runWithLogs call and a new transformation function, continuing the process as described in the previous step.