Monads, Demystified
M-Monads...? 😨
Whenever I bring up the topic of monads with my peers in a programming context, I get the impression that others would like to dismiss the topic immediately, possibly because they think monads are completely out of reach for them, or maybe they think I'm using obscurantist language to sound fancy.
This is hardly the case though. In practice, monads very elegantly encapsulate certain programming patterns and I think it would be a shame to reject understanding them out of principle.
In this article, I hope to give some motivation for monads by solving seemingly unrelated real-life problems in programming, and then stepping back to recognize and capture the underlying pattern and distilling it into the monad.
I'll assume intermediate familiarity with programming, and I'll use JavaScript/TypeScript in my code examples.
Hunting The Monad 🤠
Stripe Indexables
Stripe's API allows you to "expand" subobjects when retrieving objects. For example, a Subscription has this type signature:
type Subscription = {
...
latest_invoice: Invoice | string | null
...
}
Normally, latest_invoice will be a string | null, but if you request for latest_invoice to be "expanded", then latest_invoice will take on a shape of Invoice. Similarly, Invoices has properties that can be expanded itself,
type Invoice = {
...
payment_intent: PaymentIntent | string | null
...
}
...and so on:
type PaymentIntent {
...
payment_method: PaymentMethod | string | null
...
}
type PaymentMethod {
...
billing_details: {
...
name: string
...
}
...
}
The trouble with these type signatures is that it's really difficult to traverse nested properties. For example, if I had a subscription: Subscription and I wanted to get the name that paid for this subscription, I'd have to write something like this, if I wanted to be type-safe and runtime-safe:
const subscription = await stripe.subscriptions.retrieve(...)
const latest_invoice = subscription.latest_invoice
if (latest_invoice === null ||
typeof latest_invoice === 'string') {
throw new Error()
}
const payment_intent = latest_invoice.payment_intent
if (payment_intent === null ||
typeof payment_intent === 'string') {
throw new Error()
}
const payment_method = payment_intent.payment_method
if (payment_method === null ||
typeof payment_method === 'string') {
throw new Error()
}
const name = payment_method
.billing_details
.name;
What a chore!
I know that at each step, the object I want to index may not actually be expanded: namely the case where it has a type of string | null. Let's build a wrapper around these objects and do that logic in there:
class MaybeExpanded<A> {
private inner:
| {tag: "Expanded", value: A}
| {tag: "NotExpanded"}
private constructor(mx) {
this.inner = inner
}
static of(x: A | string | null) {
if (x === null ||
typeof x === 'string') {
return new MaybeExpanded({
tag: "NotExpanded"
})
} else {
return new MaybeExpanded({
tag: "Expanded",
value: x
})
}
}
}
All we did was move the expanded check into this MaybeExpanded.of function. We still don't have a way of actually interacting with the expanded object even if we verified that it's expanded though. So let's add a method that allows us to transform the inner value if it's expanded, otherwise do nothing.
class MaybeExpanded<A> {
...
map<B>(f: (a: A) => B): MaybeExpanded<B> {
if (this.inner.tag === "NotExpanded") {
return this
} else {
return new MaybeExpanded({
tag: "Expanded",
value: f(this.inner.value)
})
}
}
...
}
Ah, but this isn't quite what we need! Take a look:
MaybeExpanded.of(subscription)
.map(subscription => MaybeExpanded.of(subscription.invoice))
This will give us a type of MaybeExpanded<MaybeExpanded<Invoice>>, when we really wanted MaybeExpanded<Invoice>!
Let's upgrade map into flatMap, similar to how Array.flatMap "flattens" the types for us.
class MaybeExpanded<A> {
...
flatMap<B>(f: (a: A) => MaybeExpanded<B>): MaybeExpanded<B> {
if (this.inner.tag === "NotExpanded") {
return this
} else {
return f(this.inner.value)
}
}
...
}
Before bringing it back to our example, let's define one more function to make life easier:
const mindex =
key => obj => MaybeExpanded.of(obj[key])
All this function does is create a curried function that accesses an object and wraps it in MaybeExpanded. Don't worry if this doesn't make sense; it's much easier to understand it in action:
const name = MaybeExpanded.of(subscription)
.flatMap(mindex("latest_invoice"))
.flatMap(mindex("payment_intent"))
.flatMap(mindex("payment_method"))
.map(payment_method => payment_method.billing_details.name)
This looks a lot better, right? This is runtime-safe and with a little bit of type plumbing, this is actually type-safe too.
To wrap this problem up, we need a way to unwrap the value out of the MaybeExpanded wrapper. (There had to be a pun there, but the joke would be wrapped in too many layers of complexity. (Did I really choose the laziest way to make this pun? Maybe. (Or perhaps, by writing this, the pun promises to fulfill itself. (Either way, this is getting too far now.))))
class MaybeExpanded<A> {
...
unwrapOrError(): A {
if (this.inner.tag === "NotExpanded") {
throw new Error()
} else {
return this.inner.value
}
}
...
}
Logging
(This example is taken from by Bartosz Milewski's example in his series on category theory.)
Suppose your manager tells you that they need to audit the program. Specifically, they want every function in the program to log that it was called. A quick-and-dirty approach might be to add a console.log at the start of every function:
function double(x: number): number {
console.log("double called")
return x * 2
}
Ah, but your manager tells you that the program should actually log to a file sometimes. Other times, the program should accumulate the logs and send them over email. Okay, fine. How about this?
let log = ""
function double(x: number): number {
log += "double called"
return x * 2
}
// Before the program exits, send the log somewhere
As a good programmer yourself, you can feel that there's something... very wrong about this.
Part of what's wrong is that this code violates a principle I call "locality of behavior", which states that code should only be able to affect code "close" to it. In other words, there should be no "spooky action at a distance", so to speak. If I call double, I should be able to expect that double will not act on something "far" from it!
So let's refactor this a little bit:
type Logged<A> = [A, string]
function double(x: number): Logged<number> {
return [x * 2, "double called"]
}
Now, we're forced to pass in the log so that locality of behavior is preserved, and we return the new log after this function is done writing to it.
However, the return type of the function was previously a number, but we now return a Logged<number>, which is a tuple of [number, string]! If we had some code like double(square(double(x))), we'd have to change it to something like this:
function compute(x: number): Logged<number> {
const log0 = "compute"
const [x1, log1] = double(x)
const [x2, log2] = square(x1)
const [x3, log3] = double(x2)
return [x3, log0 + log1 + log2 + log3]
}
This is pretty hard to work with and it's easy to imagine this getting out of hand really fast. So let's write some tooling to make this easier.
The idea is that we want to way to "upgrade" functions of the form (a: A) => Logged<B> into a function with the signature (la: Logged<A>) => Logged<B> and concatenate the logs automatically through the upgrade.
Let me demonstrate:
const upgrade =
(f: (a: A) => Logged<B>) =>
(la: Logged<A>): Logged<B> => {
const [a, log1] = la
const [b, log2] = f(a)
return [b, log1 + log2]
}
Now, we can rewrite our computation like so:
const upgradedDouble = upgrade(double)
const upgradedSquare = upgrade(square)
function compute(x: number): Logged<number> {
const x0 = [x, "compute"]
const x1 = upgradedDouble(x0)
const x2 = upgradedSquare(x1)
const x3 = upgradedDouble(x3)
return x3
}
This looks a lot better.
Interlude
By now, you might be thinking that there aren't any patterns to the examples I've presented so far, and may be worried that this is a waste of your time or that you aren't capable of abstracting the pattern.
I will not fully assuage you of your concerns because part of the effect I want to create is the "aha!" moment where everything comes together gloriously. However, to plant the seed in your mind, I want you to direct your attention to the idea of "background" and "foreground" code.
In the Stripe example, the "background" code was checking whether something was expandable and the "foreground" code was indexing the object at each step.
In the logging example, the "background" code was concatenating the logs and the "foreground" code was doing the computations like we did before being forced to do logging.
In general, put very informally, the "background" code is what you don't really care about: it describes how to achieve your goal. The "foreground" code is what you actually care about: it describes what your goal is.
Keep this in mind as we move onto our final example.
Parsing
Suppose we need to implement a parser for a protocol our company created. The protocol is fairly simple, consisting of discrete plaintext messages containing commands to interact with a shopping cart.
You're presented with examples of messages you may receive:
clearcartmodifycart increment item 103 by 2modifycart decrement item 883 by 1
Your goal is to parse these commands into this structure for another team to handle:
type CartCommand =
| {tag: "Clear"}
| {tag: "Modify", action: CartAction}
type CartAction =
| {tag: "Increment", itemId: number, by: number}
| {tag: "Decrement", itemId: number, by: number}
Our strategy will be to use a parser combinator approach to parsing. If you don't know what that is, don't worry. We won't go into too much depth on the theory of parser combinators in this post.
The basic building block of the strategy is a Parser. For our purposes, a Parser<A> is something that reads an A from a string and returns A and the rest of the string. If the Parser failed to read an A, then it should return an error result, which will be null in our example.
type Parser<A> = (x: string) => [A, string] | null
For example, we might implement a Parser<true> like so:
const ptrue = (x: string): [true, string] | null => {
if (x.startsWith('true'))
return [true, x.slice('true'.length)]
else
return null
}
ptrue("true") // [true, ""]
ptrue("tru") // null
ptrue("true bar") // [true, " bar"]
The idea is that we can build parsers for each "atom" of our protocol and then compose them into a big parser that can then parse the entire protocol.
const integer: Parser<number> = ...
const str: (x: string) => Parser<string> = ...
const whitespaces: Parser<string> = ...
str("bar")("bar baz") // ["bar", " baz"]
whitespaces(" baz") // [" ", "baz"]
whitespaces("baz") // null
By having the primitive parsers, we can compose them into a bigger parser!
const cartActionIncrement: Parser<CartAction> = (x: string) => {
const r1 = str("increment")(x)
if (r1 === null) return null
const [, rest1] = r1
const r2 = whitespaces(rest1)
if (r2 === null) return null
const [, rest2] = r2
const r3 = str("item")(rest2)
if (r3 === null) return null
const [, rest3] = r3
const r4 = integer(rest3)
if (r4 === null) return null
const [itemId, rest4] = r4
...
return {
tag: "Increment",
itemId: itemId,
by: by
}
}
Okay, this is just awful to look at. All I want to do is parse the string "increment", some whitespaces, the string "item", some whitespaces, an item id, etc.
Let's write a way to chain these parsers easily.
const chain =
<A, B>
(pa: Parser<A>,
f: (a: A) => Parser<B>): Parser<B> =>
(x: string) => {
const ra = pa(x)
if (ra === null) return null
const [a, rest1] = ra
const pb = f(a)
return pb(rest1)
}
chain takes a Parser<A>, runs it on the input, and then if it was successful, it feeds the result to create a Parser<B> and then returns the result of Parser<B>.
Now we can rewrite our code easily like so:
const cartActionIncrement: Parser<CartAction> =
chain(str("increment"), () =>
chain(whitespaces, () =>
chain(str("item"), () =>
chain(integer, itemId =>
chain(whitespaces, () =>
...
)))))
Ah. Maybe not.
This is where we start to run up against the limits of the language. If you're familiar with older JavaScript, this might be giving you flashbacks to "pyramids of doom". The solution to Promise-based code is to use async/await, but of course, we don't have that at our disposal for Parsers.
There are actually workarounds provided by libraries like fp-ts, but let's just imagine a world where we had similar keywords parser/parse, where we could transform our code to look like this:
const cartActionIncrement: Parser<CartAction> = parser {
parse str("increment")
parse whitespaces
parse str("item")
parse whitespaces
const itemId = parse integer
parse whitespaces
parse str("by")
parse whitespaces
const by = parse integer
return {
tag: "Increment",
itemId: itemId,
by: by
}
}
Now this is starting to look really nice. In fact, if we were working in Haskell, we'd be able to do something like this for free using its do notation, which produces some incredibly elegant code.
As for parsing the rest of the protocol, I'll leave that as an exercise to the reader in our pseudo-JavaScript language 😃
No Game...? 😵💫
You might be wondering where the monads are still. Well, we've been working with them the entire time!
I'll give a formal definition of a monad and you, dear reader, should take this opportunity to go back to the previous examples and see how it applies. Afterwards, we'll have a little more discussion on monads before wrapping it up.
A monad is a generic type Monad<A> supporting the following operation:
type Bind<A, B> =
(ma: Monad<A>, f: (a: A) => Monad<B>) => Monad<B>
Technically, if we're following Haskell's definition of a monad, there needs to be a little bit more, and the operations should satisfy certain monad laws, but we won't complicate things in this post.
So in our case, MaybeExpanded, Logged, and Parser all formed monads with their respective flatMap, upgrade, and chain operations (modulo classes implicitly inserting the this argument and modulo operation order).
This probably feels incredibly unsatisfying. We picked some seemingly unrelated property that happened to be common to each example and called it a monad. I mean, we very well could have defined a "SuperMonad" to be a generic type that has the letter "e" in its name, and all of the examples would trivially have formed SuperMonads. How is this even helpful?
Monads are a very abstract concept, and it's really hard to pin down what ties all monads together without losing generality or being too abstract. However, like I hinted at before, one way to understand monads is through the "background" and "foreground" idea: the Bind operation is precisely where we put all the "background" work, allowing us to focus on the "foreground" work "inside" the monad, operating on the A in Monad<A>.
Another way to think of it may be clearer if we translate our examples into pseudo-Haskell using its do notation. Don't worry if you don't understand Haskell, we're mostly focusing on the overall shape of the code, not the details, and I'll modify some syntax to feel more familiar to JavaScript developers.
-- MaybeExpanded
getSubscriptionPayer ::
Subscription ->
MaybeExpanded String
getSubscriptionPayer subscription = do
latestInvoice <- of subscription.latestInvoice
paymentIntent <- of latestInvoice.paymentIntent
paymentMethod <- of paymentIntent.paymentMethod
return paymentMethod.billingDetails.name
-- Logged
upgrade ::
String ->
(a -> b) ->
a -> Logged b
upgrade msg f a = do
tell msg
return (f a)
double' :: Logged Int
double' = upgrade "double" double
square' :: Logged Int
square' = upgrade "square" square
compute :: Logged Int
compute x = do
tell "compute"
double' x >>= square' >>= double'
-- Parser
string :: Parser String
integer :: Parser Int
whitespaces :: Parser String
cartActionIncrement :: Parser CartAction
cartActionIncrement = do
str "increment"
whitespaces
str "item"
whitespaces
itemId <- integer
whitespaces
str "by"
whitespaces
by <- integer
return (Increment itemId by)
The point of writing it this way is to really make the foreground pop out. Within the do block, we forget that when we "bind" by using integer, what we're doing under the hood is actually parsing a string for digits and if we fail, then we short circuit the rest of the computation.
Put another way, within the context of a monad, we get to focus our attention on the important "foreground" values inside the monad, letting the monadic Bind operation take care of the underlying nasty "background" work. This lets us do magical things like saying itemId <- integer. integer has the type Parser Int, but within the monadic context, we get to "penetrate" into the monad and pretend as if we're with the underlying Int.
For example,
- Within the context of values maybe being expandable, getting the payer from a subscription is getting the
subscription.latestInvoice, and then getting thelatestInvoice.paymentIntent, and then... - Within the context of logging, my computation is telling the log the function that the computation is being run, and then running each function, which each tells the log a message.
- Within the context of parsing, a
cartActionIncrementis astr "increment"and then somewhitespaces, and then astr "item"and then anintegerthat we "bind" toitemId, and then...
I can sympathize with concerns the imperative programmer may be feeling right now. "I want to know exactly what my code is doing! These darn monads are obscuring all the work!", or "This is so confusing! You people are just trying to make things complicated on purpose! I just want to go back to my good ol' clear, imperative code!"
Hush, hush. Yes, to some degree, the "darn monads" are obscuring the work, and this is confusing at first sight.
However, the work that's being obscured is well-defined and can easily be translated by essentially placing the contents of the monadic Bind operation between every line of code. Is that so desirable? If so, I can make no further of an aesthetic argument for my case.
Regarding the code looking confusing, it totally can seem that way. However, this is not a good reason against learning or adopting monads. It can take some work to speak the language of monads, but the payoff is well worth it, in my humble opinion.
In general, that sort of mindset is horribly defeatist. Imagine if you made a similar argument when learning how to program for the first time: "This is so confusing! What are these 'variables' and 'functions'? I just want to go back to speaking good ol' clear, natural language!". (Funnily enough, some programmers seem to actually believe this these days by overusing LLMs.) Or imagine if you applied this mindset to learning a new subject in general: "Protons? Neutrons? Electrons? Screw that, I'm only going to keep working with my atoms!" But I digress.
Hidden In Plain Sight 🥸
I want to leave you, the JavaScript developer, with something surprising to think about. Remember how I earlier talked about how JavaScript solved their "pyramid of doom" problem with async/await on Promises? It's a bit of a coincidence that async/await ended up with pretty similar syntax to Haskell's do notation with monads in general, isn't it? It's almost as if...
Promise<A> was a monad all along?! 😱
That's right. Promise.then has an (implicit) signature of:
then: (pa: Promise<A>, f: (a: A) => Promise<B>): Promise<B>
This is exactly the signature for the monadic Bind operation we required. (Well, Promise.then is overloaded with other signatures, and again, there's a bit more monads formally require that Promises do satisfy but we haven't proven, but bah!)
We can now recontextualize all our async/await code as "working within the monadic context of Promises", or a way to "ignore the background work" and "focusing on the foreground work". In fact, this is literally true; there's a lot of work going on behind Promises that's put into the background through the Promise monad.
There's another common monad in vanilla JavaScript people use regularly, probably without realizing it's a monad. This one requires you to bend your mind a little bit to appreciate its monadic structure, which is why I haven't talked about it. I'll leave it as an exercise for the reader to figure out what it is.
Monads, Schmonads 😒
This was a bit of a long read, but I'm glad you made it all the way to the end. Hopefully I've convinced you that monads aren't so scary.
Really, they're just a programming pattern to separate the background from the foreground.
Put another way, working in a monadic context lets you focus on the values within the monad – the what_,_ while letting the monad handle the how with its Bind operation.
So really, monads are nothing special. The fact that the average JavaScript developer has worked with monads their entire JavaScript career without realizing it shows that monads can be understood by anyone and the mystique around monads is really fueled by a self-fulfilling prophecy.
Well, kind of. There is certainly more to monads and category theory that I didn't mention. Rather than an end-all-be-all post on monads, this post should serve as a jumping board to dive into the wonderful wacky world of functors, monads, monoids, and category theory in general.
I'll leave some further resources for wanting more:
- Bartosz Milewski's "Category Theory for Programmers"
- An accompanying lecture series also by Bartosz Milewski
- HaskellWiki's All About Monads article
- Another take on the monadic pattern, "What is a monad? (Design Pattern)" by Byte of Code
- Yet another take on the monadic pattern, "The Absolute Best Intro to Monads For Software Engineers" by Studying With Alex
If you're wondering if you should read more mathematically-focused books on category like Saunder MacLane's classic "Categories for the Working Mathematician", or Emily Riehl's "Category Theory in Context", the answer is NO!. These aren't bad books by any means, but these are geared for the mathematician and are simply not suitable for the programmer wanting to figure out how to apply category theory to their programming.
The recognition of the coming and going of things is a first step in training and practice.
– Dogen