Saturday, 16 August 2025

Monads

I've finally understood what a Monad is, and it's indeed quite astonishing how this simple concept (at least at its root) had seemed so esoteric and mysterious to me. I guess that's because the first time I heard about Monads, maybe 15 years ago, I tried to understand them through some "pure/hard functional programming" article, dealing with some types of Monads that felt pretty alien if your (my) relation with Functional programming was just "functions are objects" and "expressions are cool"! Now that "soft functional programming" is a bit everywhere, you find articles with the kind of Monads (Result and Maybe/Option) that can be used in everyday programming.

Finding a clear definition of what a Monad is does not seem so easy, and right now I can't find a link to a "friendly" definition, so I'll give you the one I came up with at some point based on different materials. A Monad is a functional Design Pattern. Think of them as wrapping a value in an object and having a method to chain actions on (apply functions to) that value, returning a new instance of the Monad wrapping the new value.

The method in the wrapping object that allows applying actions/functions is usually called bind or (flat)map, and we usually have some extra methods providing additional functionality depending on the problem addressed by that type of Monad. OK, so, why would we need to wrap a value in another object to invoke functions on it? The 2 most evident examples of that are the Maybe Monad and the Result Monad.

The Maybe Monad (also known as Option) is very useful in languages lacking the Safe Navigation operator, as it provides a safe pattern for dealing with Nullability. An example speaks by itself.


class Maybe:
    def __init__(self, value):
        self.value = value

    def map(self, func: Callable[[Any], Any]) -> "Maybe":
        if self.value is None:
            return Maybe(None)
        return Maybe(func(self.value))

    def flat_map(self, func: Callable[[Any], "Maybe"]) -> "Maybe":
        if self.value is None:
            return Maybe(None)
        return func(self.value)


@dataclass
class City:
    name: str | None
    population: int

@dataclass
class Country:
    Capital: City | None
    population: int

france = Country(
    Capital=City(
        name="Paris", 
        population=10_000_000
    ),
    population=70_000_000,
)

def get_city(country: Country | None) -> str:
    return (Maybe(country)
        .map(lambda country: country.Capital)
        .map(lambda city: city.name)
        .value or "Unknown"
    )

print(get_city(france))
print(get_city(None))

# Paris
# Unknown

Notice that we have 2 methods, map and flat_map. If we are going to apply an action that already wraps the value in a Maybe, we don't need to wrap it up again, so we use flat_map. If the action returns a value, we use map, that takes care of wrapping the value in a new Maybe.

Another common use of the Monad pattern is the Result Monad, a class for handling success/failure. There are many was to implement it, with several extra methods, representing failure as exceptions/messages/something else... I'll show here an implementation of my own (with error information represented as exceptions) so that you get the idea, but if you plan to start using this pattern, search the web for a more complete implementation.


from typing import Any

class Result:
    def __init__(self, value):
        self.value = value

    @property
    def is_success(self):
        return not isinstance(self.value, Exception)
    
    def get_or_default(self, default: Any = None):
        return self.value if self.is_success else default
    
    def error_or_default(self, default: Any = None):
        return self.value if not self.is_success else default    
    
    def map(self, func):    
        if not self.is_success:
            return self
        try:
            return Result(func(self.value))
        except Exception as e:
            return Result(e)
        
    def flat_map(self, func):
        if not self.is_success:
            return self
        try:
            return func(self.value)
        except Exception as e:
            return Result(e)


We can use it like this:


from result import Result
from dataclasses import dataclass

class ValidationError(Exception):
    pass

@dataclass
class User:
    name: str
    age: int
    email: str

def validate_name(user: User):
    return Result(user) if user.name and user.name.strip() else Result(ValidationError("Name is required"))

def validate_age(user: User):    
    return Result(user)if user.age >= 18 else Result(ValidationError("Must be at least 18"))

def validate_email(user: User):
    return Result(user) if "@" in user.email else Result(ValidationError("Invalid email"))


def register_user(user: User):
    result = (validate_name(user)
        .flat_map(validate_age)
        .flat_map(validate_email)
    )
    if result.is_success:
        return result.value
    else:
        raise result.value


for user in [
    User(name="John", age=20, email="john@example.com"), 
    User(name="Jane", age=16, email="jane@example.com")
]:
    try:
        register_user(user)
        print(f"User registered: {user}")
    except ValidationError as e:
        print(f"Validation error: {e}")

# User registered: User(name='John', age=20, email='john@example.com')
# Validation error: Must be at least 18


Notice that the Maybe and Result Monads are special cases (providing better semantics) of the more generic Either monad. Just from a GPT:

  • Either Monad:
    The Either monad is a general-purpose structure that can hold either a value of type A or a value of type B. The "left" and "right" types are not predefined and can represent any two distinct types.
  • Maybe Monad:
    The Maybe monad is a specific case of Either where the "left" side is often represented by "Nothing" (or None in some languages), indicating the absence of a value, while the "right" side represents a successful value of type T. This is often used for situations where a computation might not always produce a result.
  • Result Monad:
    The Result monad is another specific instance of Either, where the "left" side is used to represent an error (e.g., an error object or a string describing the error) and the "right" side represents a successful result of type T. This is commonly used for handling errors in functional programming, where exceptions are avoided in favor of returning error values

Kotlin comes with a Result class in its stdlib to manage success and failure, but it's not a Monad as it lacks the chaining functionality (no flatMap/bind whatever). So people have come up with different implementations, like the one in the Arrow Functional library or this one. You can also turn the standard Result class into a Monad via extension methods.

Maybe at this point you have already realized that if you've done any JavaScript programming in the last 15 years it's very likely that you've been using Monads without being aware, as Promises, are a clear example of Monads. They wrap a value (a value that will be available in the future) and allows chaining operations on that value via the then() method. It's particularly interesting that the then() method acts both as flat and flatMap, as it can handle functions that return a value or a Promise. You can further read here.

There are several interesting Python articles about Monads, like [1], [2], [3].

The last link includes examples of a different kind of Monads (State, IO, Reader, Writer), that I think make sense only when you're working with "hard funtional programming". I have nothing to say about them, I just mention them here for completeness.

No comments:

Post a Comment