Monad
musings by Bast on Friday February 14th, 2025
Should I add another monad explantion to the world? Probably not. But this my blog. So here's my (poor) attempt at explaining what a monad is.
A monad is a monoid in the category of endofunctors, what's the problem?--James Iry A Brief, Incomplete, and Mostly Wrong History of Programming Languages
A monad is somewhat halfway between a function and a type. It's a type, but not a complete type. It's a function, but only in a certain way.
A monad is a type that wraps a value to add additional behavior to it, that additionally forwards operations to the value inside when appropriate.
Maybe/Optional/Nullable/List/Promise (sort of) are Monads.
Most explanations of Monads fall short. They confuse the reader by using haskell-specific or functional-language-specific terminology like Bind or Unit. They dally into brief yet incomplete mentions of Category Theory (which is a highly abstract, dense, and difficult field to understand, especially for someone who's not looking for a thorough primer on an entire field of study to understand a concept). They routinely digress into haskellian syntax which is widely known to be very pretty, but also very terse and inexplantory.
They rely excessively upon the Optional or Maybe type as an example, but mention List[] without explaining why List, of all things, is also a monad. They bring up flatmap without explaining what a flatmap even is.
But don't worry, I will!
A flatmap is a combined flatten-map operation: first it maps() a function across the data inside a container, then it flattens the return results. In the common parlance, if you wanted to parse words:
def parse_hypenated_words(string: str) -> list[str]:
words = string.split()
words = list(flat_map(lambda word: word.split("-"), words))
return words
This code splits the given text into words, and then it re-processes each word, dividing it into further pieces based on hyphenation. The applied (mapped) function returns a list, so you would expect map(function, words)
to return a list of lists. Hence the flatten, which removes one layer.
This is also useful when it comes to fallible operations, or the Result
type:
fn parse_small_integers(data: &str) -> Vec<Option<u8>> {
data.split(" ")
.map(|x| u8::try_from(x).ok())
.flat_map(|x| {
if x < 10 {
Some(x)
} else {
None
}
})
Without the flat_map call, this would return Vec<Option<Option<u8>>> and you would need to double-unwrap to get the value. But we don't particularly care about differentiating the middle case here: a small integer that's greater than 10 but less than 256 is only treated separately due to how we chose to implement it.
We could instead write this as .map().map().flatten(), of course. That's why the existence of flat_map isn't particularly important for defining what a monad is, although it is a useful look into the properties monads have.
Anyway I digress.
From here, you may have realized why List (or, in the case of rust here, Vec) is a monad. It contains a type, or types. You can transform them within said list without changing the fact it is a list. And the list itself adds additional properties to the objects within without changing their fundamental nature.
Maybe<str> is a monad because:
- It contains something/modifies something/a type
- You can operate on it's contents via .map(), which transforms it "through" the monad if it's a value, or does nothing if the Maybe<> is Nothing
In haskellian terms (which I'm sure, by the time you get here, you've seen a thousand times, but it bears translating 1-1):
- Wraps a type:
a -> m a
- Is operable like the original, with variance:
m a -> (a -> m b) -> m b
.
Monads are powerful, manipulable abstractions over a wide swath of generic objects. You can use them to represent the potential absence of a value, either due to error or omission. You can use them to represent the future promise of a value (Promise, or with lazy computation). You can use them to represent groups of items, clusters of items. Or for logging. In fact, decorators are a variation of impure monads:
def log(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"{func.__qualname__} called with {args=} {kwargs=}")
return func(*args, **kwargs)
return wrapper
It satisfies the rules. log(F) returns a wrapper of F, that behaves just like F with additional detail. It's also sacriligious: It lacks a .map() to compute on the inner value, so is technically not a monad, and does IO to boot.