The term “monad” is often invoked when describing patterns in functional programming. Yet explanations typically swing between high-level metaphors and deep mathematical abstractions. Each approach offers part of the picture, intuition without precision, or rigor without intuition but seldom both.
Monads can be idealized as a container (albeit is a flawed metaphor) or context holding a value (or multiple values, or no value), but in some cases we will get into later on it’s better to think of it as a recipe or deferred computation for producing a value. At the heart of monadic programming is the idea that you write one function, say, f(x) = x + 1
, and then reuse it across different contexts without rewriting control-flow logic.
Two Flavours: Result vs Recipe
While all monads provide a computational context, they generally fall into two flavors:
- Monads as “Results”: These represent a value that has already been computed, but with extra context.
List<int>
is a result with the context.Maybe<int>
is the result of a computation with the context of “possible optionalness.” For these, the “container” metaphor is a useful, if limited, starting point. - Monads as “Recipes”: These represent a computation that has not happened yet. They are a blueprint for producing a value. C#’s
Task<T>
is a perfect example: it doesn’t hold a value, it holds the promise of a value to be computed asynchronously. For these, the “recipe” metaphor is a much better fit. Unwrapping a task doesn’t give you the computed result, it just gives you the instructions to compute it.
Sometimes, you can mix the flavors. In this post, we will focus on the first category to build our core intuition. We’ll start with List
and Maybe
to understand the mechanics of map
and flatMap
on concrete results. In Part 2, we’ll see how these same patterns unlock immense power when applied to “recipe” monads like Task
.
List: Map & FlatMap in Practice
To an OOP developer, monadic types (List<T>
) might look just like generics. It’s a typical pitfall to think “we have generics, so we have monads,” which isn’t true by itself. Monads do usually involve generic types, but they require specific operations (Unit and flatMap) and the three monad laws on those types to ensure uniform behavior. This is key and is fundamental to working with monads.
A good example of a monad is a list. You’re likely very familiar with lists and working with lists.
The monad Map operation is responsible for:
- Applying your function. For a
List
,Map
runsf
on every element, so the list[0,1,2,3]
becomes[1,2,3,4]
. If the list doesn’t have any elements, then, well,Map
doesn’t callf
.f
doesn’t need to worry about that. Also,f
doesn’t care if it’s a list, allf
is, is justf(x) = x + 1
.Map
is responsible for running it. - Managing sequencing and combination. The list context concatenates results into one list. We don’t need to manually modify or re-add elements to the list via
Add
.
Notice that the monad is responsible for running f(x)
. This shift means your business logic stays declarative and composable, you describe what happens to a single value, and the monad describes how and when it happens.
This is different from OO and procedural programming because in procedural programming, if you want to process data, it is your responsibility to understand how to apply the function to your data. We have to use different control constructs to handle different types of data, and are also responsible for the “how”.
Here’s examples in C#:
public string f(string input) {
return input + " -appended text";
}
// 1. List<string>: you must foreach and build a new list
var fruits = new List<string> {
"apple",
"banana",
"cherry"
};
var newFruits = new List<string> ();
foreach (var fruit in fruits)
{
newFruits.Add(f(fruit));
}
// 2. Single string: you must check for null first, then concatenate
string userInput = GetUserInput(); // could be null
if (userInput != null)
{
userInput = f(userInput);
}
// userInput could still be null here, or, it could be the concatenated result
// 3. Dictionary<string, string>: you must know it’s key/value pairs
var dict = new Dictionary<string, string>
{
["a"] = "alpha",
["b"] = "beta",
["c"] = "gamma"
};
// can’t modify while iterating, so capture keys first
foreach (var key in dict.Keys.ToList())
{
dict[key] = f(dict[key]);
}
In this example, we are forced to deal with knowing how to procedurally update these structures. For a List
, we have to call Add
, for the String
we can update it in place, for the Dictionary
, we have to access the keys. We have to know it’s a List
beforehand so we know to use foreach
. We have to know it’s just a string to append another string to it. We have to know it’s a Dictionary
to know how to iterate and update its keys.
But you still need to foreach
, loop, etc. Instead, monads delegate control flow to itself and are responsible for knowing how to update the underlying value(s). Recall, however, that even the simplest of monads (essentially containers) must implement two methods in order to be monads (Unit and flatMap), and also follow three monad laws.
Unit
Unit moves a raw value into the monadic context, sometimes called “lifting”, “identity”, “return”, “wrap”, or “promotion”, or some fancy operation names like “liftM” or “liftA”.
-
In the list monad (let’s just call it a list), Unit takes a single element and returns a list containing that element.
-
For example, given the integer
1
, Unit produces a list via:
Example (C#)
var list = new List<int> { 1 };
Nothing about the value 1
changes, it’s simply wrapped in a List
. If you access element 0
of that list, you get back 1
. That’s it.
Map
Map applies a function to each value inside the monad.
In the List
, Map
runs a function on every element and outputs a new changed list with that function applied to each element. Don’t overcomplicate it. For example, say there is a function that just adds one to its input, for example f(x) = x + 1
. Then, passing this function to Map
would simply add one to each element in the list. The list [0,1,2,3]
would become [1,2,3,4]
.
Example (C#):
var originalList = new List<int> { 0, 1, 2, 3, 4 };
var mapped = originalList.Map(x => x + 1); // Map doesn’t exist in C# (and instead is called Select in LINQ) but just use this as pseudocode
Example (C#, procedural):
var originalList = new List<int> { 0, 1, 2, 3, 4 };
var mappedList = new List<int>();
foreach (int x in originalList)
{
mappedList.Add(x + 1);
}
How do you get the damn values out of the monads?
You kind of don’t really want to take them out per-se, unless required. It’s possible to implement a GetValue()
method that just returns the underlying value, but when the value leaves the monadic context, we lose its benefits and can no longer compose them.
Recall that a List
is a monad, and pretend that it’s your first time using a list. You might say, I don’t want my values trapped in this list, how am I supposed to use them? Then proceed to take them out as separate variables and pass them around individually.
// Pretend it’s your first time with a List<T>
var numbers = new List<int> { 1, 2, 3 };
// --- Manual extraction (values “trapped” in the list) ---
var a = numbers[0];
var b = numbers[1];
var c = numbers[2];
// You’d then have to call your function separately on each:
var r1 = AddOne(a);
var r2 = AddOne(b);
var r3 = AddOne(c);
But in doing so, you lose the advantage of lists, that is, the ability to store arbitrarily long sequences, the ability to pass all of the values at once, to concatenate with other lists, and the ability to iterate through the items. If you want to add one to each of the items, then you’ll have to individually address each variable and add one to it. It’s very tedious.
Up to this point, monads only look like fancy containers that have to implement two weird methods, Unit and flatMap.
Maybe
Let’s move to a slightly more complex example where it may not always make sense to unwrap or “get at” the underlying value. Let’s create a monad called Maybe
, which holds an already-computed result (or the absence of one.)
For simplicity, our MaybeMonad will hold an int
, but in a real-world library, this would be a generic Maybe<T>
. It’s not exactly a monad yet, because it doesn’t implement flatMap
(we’ll get to this in part 2.)
public class MaybeMonad {
private int value;
private bool hasValue;
// Unit
public MaybeMonad(int value) {
this.value = value;
this.hasValue = true;
}
// Unit
public MaybeMonad() {
}
// Map
public MaybeMonad Map(Func<int, int> func) {
if (hasValue) {
return new MaybeMonad(func(value));
}
return this;
}
}
The Unit operation is just calling the constructor, that is, there has to be a way to move a value to be inside of a MaybeMonad
. The Map
operation might feel a bit awkward, because it’s just a single value. You might be used to mapping over a List
.
Here’s an example to add 1
to a MaybeMonad
.
var age = new MaybeMonad(30);
var newAge = age.Map(x => x + 1);
// newAge is now 31
Or,
var age = new MaybeMonad();
var newAge = age.Map(x => x + 1);
// newAge is still nothing, Map decided it should not run f(x) because there is no value
It may look very verbose. Why do I have to type all of this stuff to add 1
to a number? The issue is that age is a MaybeMonad
, which defines it as a value that may or may not exist. In the case where there is no value, MaybeMonad
doesn’t execute f(x)
. You would have to do the same thing if you wrote it procedurally.
Procedurally, we would have written an if statement to check if there was a value, then update it conditionally. So, we would have to be responsible for how to run this function:
int? age = null;
if (age != null) age++;
Or,
int? age = 30;
if (age != null) age++;
We can start to see why a monad is not simply a container, or something to be unwrapped. How does one unwrap a MaybeMonad
? If it has a value, it’s straightforward, just return the value. If there isn’t a value, then, well, there’s nothing in the container. Nothing
is an abstraction the MaybeMonad
defines, it isn’t representable via null because null is something, it is null. MaybeMonad
defines that no computations can run in the case that a value does not exist. This is where unwrapping the container, or a box doesn’t always make sense.
This means that you can chain computations that themselves return Maybe
s, then compose them. The issue with only having Map
is that we may end up with extraneous nested containers. For example, let’s say a function returns a Maybe<int>
. If we chain it with Map
, then the input to that function is also a Maybe<int>
, which itself gets wrapped into a Maybe, giving a Maybe<Maybe<int>
. We need another way to chain the computations, but to avoid having the unnecessary nested containers as we compose monads.
flatMap
flatMap
is like our Map
, but it also flattens the result. flatMap provides the ability to chain computations that themselves produce monadic values, which is the defining feature of monads. For example, if you have a function that looks up a user and returns a Maybe<User>
, but you want to pass it to another function that returns the user’s profile. Using Map would give you a Maybe<Maybe<UserProfile>>
, an awkward nested container because the input would be a Maybe<UserProfile>
. With flatMap
, you both apply your lookup and collapse the layers in one go, so you can seamlessly sequence optional, error-handling, or asynchronous operations (e.g. promises/tasks) without ever wrestling with nested monadic types.
Maybe<User> lookupUser(string id)
{
// Call your data‐access method which already returns Maybe<User>
return GetUserFromDatabase(id);
}
Maybe<string> userIdMaybe = GetUserId();
// Map gives Maybe<Maybe<User>> (nested container) because LookupUser returns a Maybe<User>
// This quickly becomes unwieldy, and these nested containers do not help us and make it difficult to process values later on
var nested = userIdMaybe
.Map(lookupUser);
// flatMap collapses it to Maybe<User>
var user = userIdMaybe
.FlatMap(lookupUser);
flatMap
is arguably much more important than Map, in fact, flatMap
is a requirement to implement a monad and itself can implement Map
.
Procedurally, it might look like this:
string userId = GetUserId(); // could be null
if (userId == null) {
// throw an error, stop, etc.
}
var user = GetUserFromDatabase(userId); // user is a User or null
if (user == null) {
// error here
} else {
// user is valid
}
In the procedural example, notice that we have to specify the control flow ourselves, however, in the monadic example, control flow is implied through the monads. If userIdMaybe
doesn’t contain a value, then flatMap
just doesn’t execute lookupUser
.
In the mondaic example, you could write:
Maybe<string> userIdMaybe = GetUserId();
Maybe<User> = userIdMaybe.FlatMap(lookupUser);
The control flow is handled by the monads. GetUserId
returns a Maybe
because we’ve defined Maybe
as something that may or may not have a value. Its semantics and how it runs subsequent functions are inside of Maybe
. This means that if userIdMaybe
doesn’t have a user id, then lookupUser
doesn’t run. This is not mysterious, we’ve defined Maybe
to not run subsequent functions when there is no value.
This is why it makes sense to have everything as monads, so that you can chain the Maybe
monad with other monads, like a pipeline and keep it in the mondaic context.
If you try to take the value out right away (let’s assume we had a GetValue()
command that returned the value, or null
otherwise):
Maybe<string> userIdMaybe = GetUserId();
var actualUserId = userIdMaybe.GetValue();
if (actualUserId != null) {...}
Eww. If it’s treated as simply a container, then it looks like a waste of time, why am I putting a value into this and just taking it out again immediately? What’s the point? I think this is where a lot of people stop when studying monads (myself previously.) You might think it’s just a burrito, a container, some box, or a package, and there are some fancy academic math stuff with this container thing. In part 2, we’ll go over more advanced monads, which are not simply containers.
Monad Laws
To be a true monad, a type must not only have Unit and flatMap operations but also obey three simple laws. These laws ensure that chaining operations behaves predictably.
- Left Identity:
Unit(x).flatMap(f)
is the same asf(x)
. (Wrapping a value and then immediately applying a function is the same as just applying the function to the value). - Right Identity:
m.flatMap(Unit)
is the same asm
. (Applying the simplest possible wrapping function shouldn’t change the monad). - Associativity:
m.flatMap(f).flatMap(g)
is the same asm.flatMap(x => f(x).flatMap(g))
. (The order in which you group chained operations doesn’t matter).
You don’t need to memorize these, but they are the mathematical guarantee that allows monads to be composed so reliably. Our MaybeMonad
follows these laws, making it a true monad.
We’ve seen how monads provide a context for computation. By defining two core operations, Unit (to wrap a value) and flatMap (to sequence operations that return a new context), we can abstract away control flow like loops and null-checks. This turns scattered procedural code into a single, declarative expression.
The real power comes from applying this pattern to different contexts. In Part 2, we’ll explore other useful monads like Either for more descriptive error handling and see how to compose them together to manage multiple concerns at once.