A list is a monad

原始链接: https://alexyorke.github.io//2025/06/29/a-list-is-a-monad/

Monads in functional programming provide a context for values, enabling consistent behavior across different types without rewriting control flow logic. There are two flavors: "Result" monads (like `List` or `Maybe`) which represent computed values with extra context, and "Recipe" monads (like `Task`) which represent deferred computations. Essential to monads are `Unit` (wrapping a value) and `flatMap` (sequencing operations). `flatMap` avoids nested containers that can occur with multiple maps in a row. For example, `Maybe` handles optional values, executing functions only if a value exists, thereby encapsulating null checks. Monads aren't just containers; they delegate control flow. To be a true monad, a type must adhere to three laws: Left Identity, Right Identity, and Associativity. These laws guarantee predictable composition and reliable chaining. This pattern abstracts control flow and allows for declarative expression.

This Hacker News thread discusses a tutorial explaining monads using lists as an example. Commenters debate the best way to understand monads, with some advocating for learning specific monad instances like Maybe and IO before tackling the general concept. Others argue that understanding the underlying mathematical rigor, specifically the commutative property of monads, is crucial. Several commenters address the value of the Monad interface, emphasizing its ability to enable generic code composition and syntax sugar like `do` notation. They argue that while individual monads function differently, the shared interface allows for powerful abstractions, similar to iterators. Some point out that monads are not about adding capabilities to data types, but about providing a consistent interface for operations across different types. The discussion also highlights that understanding monads can be challenging due to limitations in type systems of many languages and that while mastering monads can be beneficial, it is not strictly necessary for a successful software engineering career.
相关文章

原文

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 runs f 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 call f. f doesn’t need to worry about that. Also, f doesn’t care if it’s a list, all f is, is just f(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 Maybes, 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.

  1. Left Identity: Unit(x).flatMap(f) is the same as f(x). (Wrapping a value and then immediately applying a function is the same as just applying the function to the value).
  2. Right Identity: m.flatMap(Unit) is the same as m. (Applying the simplest possible wrapping function shouldn’t change the monad).
  3. Associativity: m.flatMap(f).flatMap(g) is the same as m.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.

Part 2

联系我们 contact @ memedata.com