Skip to Content
Course content

51: Building a Simple Monad

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I remember working on a legacy payment integration a few years back where every single API call could potentially fail for a dozen different reasons. I started the task with a few simple match statements, but as the business logic grew, I hit what I call the "Pyramid of Doom." I had five or six levels of nested if and match blocks just to handle the possibility of a null response or a network timeout. By the time I reached the actual logic for processing the payment, my code was indented so far to the right that it was practically falling off the screen. I spent more time managing the "plumbing" of the errors than I did writing the actual financial logic.

That's the exact problem Monads solve. While the academic definition of a Monad can feel like a wall of category theory, in practical Scala, it's just a design pattern that lets us chain operations together while the "wrapper" handles the boring stuff—like null checks, error handling, or logging—behind the scenes.

Defining the Wrapper and the Entry Point

To build a Monad, you first need a way to put a raw value into a context. We call this pure or unit. In Scala, we usually implement this as an apply method in a companion object. Let's build a simple Box[A]. In a real scenario, this might be a Result or a Validation type, but for now, let's just treat it as a container that might or might not hold a value.

sealed trait Box[+A]
case class Full[A](value: A) extends Box[A]
case class Empty() extends Box[Nothing]

object Box {
  def apply[A](value: A): Box[A] = Full(value)
}

Right now, we have a way to get a value into the Box, but we can't actually do anything with it without unpacking it using a match statement. If we do that every time, we're right back at the Pyramid of Doom.

The Engine: Implementing FlatMap

The real power of a Monad comes from flatMap. This is the engine that allows us to chain computations. The rule for flatMap is simple: it takes a function that transforms the value inside the box into another box. This prevents us from ending up with a Box[Box[A]], which would be a nightmare to manage.

I like to think of flatMap as a contract: "If the box is full, run this function. If it's empty, just stop everything and pass the empty box along."

sealed trait Box[+A] {
  def flatMap[B](f: A => Box[B]): Box[B] = this match {
    case Full(value) => f(value)
    case Empty()     => Empty()
  }

  def map[B](f: A => B): Box[B] = 
    flatMap(a => Box(f(a)))
}

Notice how I implemented map using flatMap. This is a common pattern in functional programming. Once you have flatMap, map becomes a trivial convenience. Now, instead of nesting matches, we can chain operations linearly. If any step in the chain returns an Empty, the rest of the chain is skipped automatically. You've effectively abstracted the "failure" logic away from your business logic.

It's a subtle shift, but it changes how you think about data flow. You stop writing "if this is true, then do that" and start writing "this sequence of transformations should happen to this value."




📋 Practical Task

Implementing a Logging Monad for Execution Traces

One of the most useful ways to use a Monad is to track metadata about a computation without polluting the return types of your functions. Your task is to build a Logged[A] Monad.

Instead of just holding a value, the Logged[A] container should hold both the value of type A and a List[String] that acts as a log of every operation performed on that value.

  • Create a case class Logged[A](value: A, logs: List[String]).
  • Implement a companion object Logged with an apply method that initializes the log as an empty list.
  • Implement a flatMap method that runs the provided function and appends the new logs from the resulting Logged object to the current logs.
  • Implement a map method using your flatMap.

Test your implementation: Create a chain of three operations (e.g., adding 10 to a number, multiplying by 2, and converting it to a string) where each step adds a unique message to the log. Verify that the final result contains both the correct transformed value and the complete history of logs in the order they occurred.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.