Understanding the Monad Design Pattern in Kotlin

Lasantha Kularatne posts kotlin functional-programming design-patterns monads

Monads have a reputation for being difficult to understand, often explained with abstract mathematical concepts that leave developers more confused than enlightened. In this post, I'll take a practical approach - implementing monads in Kotlin to show how they solve real problems.

What Problem Do Monads Solve?

Consider this common scenario: you have a chain of operations, each of which might fail or return null. Traditional approaches lead to nested null checks or try-catch blocks:

fun getUserEmail(userId: String): String? {
    val user = findUser(userId) ?: return null
    val profile = user.profile ?: return null
    val email = profile.email ?: return null
    return email
}

Monads provide a cleaner way to chain these operations while handling the "failure" case automatically.

Starting with Functors

Before understanding monads, we need to understand functors. A functor is simply a container that supports a map operation - it lets you transform the value inside without unwrapping it manually.

interface Functor<out A> {
    fun <B> map(f: (A) -> B): Functor<B>
}

From category theory, functors must satisfy two laws:

  1. Identity: functor.map { it } should equal functor
  2. Composition: functor.map(f).map(g) should equal functor.map { g(f(it)) }

The Monad Interface

A monad extends functor by adding flatMap (also known as bind). This is the key operation that enables chaining:

interface Monad<out A> : Functor<A> {
    override fun <B> map(f: (A) -> B): Monad<B>
    fun <B> flatMap(f: (A) -> Monad<B>): Monad<B>
}

The difference between map and flatMap:

  • map transforms A -> B inside the container
  • flatMap transforms A -> Monad<B>, then flattens the result

Implementing MayBe: A Practical Monad

Let's implement a MayBe monad (inspired by Haskell's Maybe) for handling optional values:

sealed interface MayBe<out A> {
    fun <B> map(f: (A) -> B): MayBe<B>
    fun <B> flatMap(f: (A) -> MayBe<B>): MayBe<B>
    val isJust: Boolean

    fun getOrNull() = when (this) {
        is Just -> value
        is None -> null
    }

    data class Just<out A>(val value: A) : MayBe<A> {
        override fun <B> map(f: (A) -> B) = Just(f(value))
        override fun <B> flatMap(f: (A) -> MayBe<B>) = f(value)
        override val isJust = true
    }

    data object None : MayBe<Nothing> {
        override fun <B> map(f: (Nothing) -> B) = None
        override fun <B> flatMap(f: (Nothing) -> MayBe<B>) = None
        override val isJust = false
    }
}

The beauty here is in how None handles operations - it simply returns None, short-circuiting the entire chain without explicit null checks.

An Alternative: Optional Monad

Here's another implementation with a factory method for easy construction:

sealed interface Optional<out A> {
    fun <B> map(f: (A) -> B): Optional<B>
    fun <B> flatMap(f: (A) -> Optional<B>): Optional<B>

    fun getOrNull() = when (this) {
        is Some -> value
        is None -> null
    }

    companion object {
        fun <B> of(value: B?): Optional<B> =
            if (value == null) None else Some(value)
    }

    data class Some<out A>(val value: A) : Optional<A> {
        override fun <B> map(f: (A) -> B) = of(f(value))
        override fun <B> flatMap(f: (A) -> Optional<B>) = f(value)
    }

    data object None : Optional<Nothing> {
        override fun <B> map(f: (Nothing) -> B) = None
        override fun <B> flatMap(f: (Nothing) -> Optional<B>) = None
    }
}

Using Monads in Practice

Now our earlier example becomes elegant:

fun getUserEmail(userId: String): Optional<String> {
    return Optional.of(findUser(userId))
        .map { it.profile }
        .map { it.email }
}

Notice how clean this is - just chained map calls! This works because our Optional.map implementation uses of() internally:

override fun <B> map(f: (A) -> B) = of(f(value))

If any step returns null, of() converts it to None, and subsequent operations short-circuit automatically.

So when do you need flatMap? When your function already returns an Optional:

fun findUserProfile(userId: String): Optional<Profile> { ... }

fun getUserEmail(userId: String): Optional<String> {
    return Optional.of(findUser(userId))
        .flatMap { findUserProfile(it.id) }  // returns Optional<Profile>
        .map { it.email }
}

The rule:

  • map - when your function returns a plain value (A -> B)
  • flatMap - when your function already returns an Optional<B>

No null checks, no early returns - just a clean pipeline of transformations.

Why Not Just Use Kotlin's Nullable Types?

Kotlin's ?. safe call operator is excellent, but monads offer additional benefits:

  1. Composability - Monads can be combined and chained in powerful ways
  2. Abstraction - The same pattern works for error handling, async operations, and more
  3. Explicit semantics - Optional.None vs null makes intent clearer
  4. Railway-oriented programming - Easy to build pipelines with automatic error propagation

Beyond Optional: Other Monads

The monad pattern applies to many scenarios:

  • Result/Either - For operations that can fail with an error
  • IO - For encapsulating side effects
  • Future/Promise - For async operations
  • List - Yes, lists are monads too!

Source Code

The full implementation is available on GitHub: kotlin-demo

The repository includes additional functional programming examples like ProducerFunctor and TripletFunctor.

Conclusion

Monads aren't magic - they're a design pattern for composing operations on wrapped values. Once you understand map and flatMap, you've understood the core concept. The rest is just applying it to different scenarios.

The key insight is that monads let you focus on the "happy path" while the container handles the edge cases (null, errors, async completion) automatically.

References

Happy functional programming!