Skip to content

Instantly share code, notes, and snippets.

@fatcerberus
Last active February 24, 2024 07:58
Show Gist options
  • Star 34 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save fatcerberus/beae4d15842071eab24fca2f0740c2ef to your computer and use it in GitHub Desktop.
Save fatcerberus/beae4d15842071eab24fca2f0740c2ef to your computer and use it in GitHub Desktop.
Monads for the Rest of Us

Monads for the Rest of Us

by Bruce Pascoe - 1 May, 2019

"A monad is just a monoid in the category of endofunctors. What's the problem?" ~James Iry1

The problem... is that there are several problems.

It's been said that monads bear a dreadful curse. Once you finally understand what they are, you begin to see them everywhere--but somehow become completely incapable of explaining them to anyone else. Many tutorial writers have tried to break the Great Curse--the Web is lousy with bold attempts and half successes that attest to this--and just as many have failed. Well, I'm here to address the elephant in the room2 and tell you that I intend to break the Great Curse once and for all.

There are basically two ways a monad tutorial tends to go. One is a paragraph or two of minimal descriptions of one or two common monads (Haskell's Maybe in particular is very popular), followed by a lot of intimidating Haskell syntax trying to explain--precisely--how it all fits together. This is well-intentioned but misguided; while we as programmers do tend to think in code, and pseudocode can convey a lot of information very quickly, in this case it's for naught; the underlying programming paradigm Haskell exemplifies is entirely different from how someone coming from an imperative or object-oriented background intuitively thinks about their "world". The tutorial writer may as well be speaking gibberish for all the code samples are worth in practice.

The other way this can go also involves a bunch of Haskell syntax, but first tries to explain the idea of a monad by rigorous mathematical definition, usually by stating the laws they obey (it can be worse--some just dive right into category theory nonsense!). This, at least in my experience, is perhaps even worse than the above; while I do now know what a monad is (and I'm going to teach you!), I still don't find the laws themselves that useful for reasoning about how monads behave--much less for understanding what they represent. Besides, telling someone the identity laws right off the bat, even assuming they understood them, doesn't give them any information on why they should want these things in their programs in the first place--or why they should even care!

So I'm going to try a different approach, one that acknowledges the ways in which imperative programmers (like me!) already think about their world and avoids getting into advanced mathematics that won't help for understanding the concepts in real-world terms anyway. If you can multiply numbers together and know what a function is, you can understand monads, I promise you.

Without further ado, onto the tutorial!

What is a function?

In most imperative and object-oriented languages, a function can be thought of as a subroutine that takes one or more values as input (called parameters), perhaps performs some action, and produces a single value (called the return value) as output. We can call up the function as many times as we want, with whatever inputs we want, and get the correct output.

Some people will go out of their to way to stress that the mathematical definition of a function is much narrower than how programmers use the term (mathematical functions don't permit "side effects", for example, and always return the same output for the same input), but that's not relevant to us. Anyone who tells you functional purity is important for understanding monads in a programming context is lying to you (and probably has an agenda).

But I digress. Functions are very useful:

function add(x, y) {
	return x + y;
}

let x = add(2, 2);  // assigns 4 to variable x
let y = add(3, 3);  // assigns 6 to variable y

We already know why this is useful: A computation may consist of several lines of code, and the code to perform that computation doesn't particularly care what the exact value is, so why write it more than once?

Boilerplate... boilerplate everywhere

So a function lets us write code once to handle any value (or values) we want to throw at it, as many times as we need to do so. The only restriction, if any, is that the value is always a particular type--for example, a string, or a number. This is fine: The code to do a similar computation on a different type would necessarily be different, so we can afford to write another function to handle that.

Often though (way more often than we consciously realize!), we find ourselves writing exactly the same code, over and over and over again, regardless of the type of values involved. For example, we might write a for loop to process all the elements of an array:

// old school!
for (let i = 0; i < list.length; i++) {
	let value = list[i];
	// do stuff with each value
}

// new style using ES6+ iterators, shiny!
// (but we still have to write the loop every time...)
for (let value of list) {
    // do stuff with each value
}

Or, perhaps we're calling functions that can return null instead of a meaningful value to indicate failure. If at any point we get a null, we have to bail out because the world is no longer in the state we expect and it's meaningless to continue (i.e. the null is not actually a real value we can work with, it's just being used as a sentinel):

let file = openFile("log_file.txt");
if (file === null)
	return false;
let result;
if (f.write(file, "day #9001: a pig ate everyone today.") === null)
	return false;
if (f.close(file) === null)
	return false;

And these are just the tip of the iceberg. Suffice to say there are plenty of cases where we write the same code over and over again, no matter what kind of data we're working with. It doesn't matter if that array contains a bunch of strings, numbers, or awesome flying pigs, you're still going to write that exact same for loop for the eleventy billionth time. Same goes for the error checks: you will need to check what that flaky I/O function returned every single time you call it lest you accidentally end up in an invalid state and open a wormhole to the eaty pig dimension or something.3 Way to go, you just got us all eaten by forgetting to detect an error condition. I hope you're proud of yourself!

Eaty pigs aside, here's the problem we have: The code that doesn't care about the value(s) is intertwined with the code that does, and all this has to happen in the proper order otherwise, well, hello pig dimension. So we can't just tease it apart, can we? Actually, it turns out we can. But we're going to have to work up to it.

Context is everything

At this point everyone usually breaks out a box metaphor. It's serviceable as far as mnemonic devices go, but it's not really that great for introducing the theory (more on that later) so let's try a different approach. Let's suppose we have this simple function:

function square(x) {
    return x * x;
}

We call it with some number, it gives us back that number raised to the second power. Easy, right? So now we have our function, and we can use it to compute the square of any numeric value that happens to be lying around:

let x = 812;
x = square(x);  // x = 659344

...and we can use the same function to square every value in some list:

for (let i = 0; i < numbers.length; ++i) {
	numbers[i] = square(numbers[i]);
}

We can even use it with a value that might not exist, as long as we've checked that it does exist first:

if (value !== null) {
	value = square(value);
	// maybe do other stuff with it here
}

So the values themselves always work the same way, but we're using them in different contexts. The value is inherently entangled with its context, but our function doesn't care about the context, so we have to disentangle it first. After all, it's our responsibility to call the function, right? It seems we're doomed to write the same code over and over and over again... for the rest of eternity...

Don't call me, I'll call you

You're probably familiar with the concept of callback functions. The idea here is that we give the browser (or whatever) a function, and it arranges to call us back through that function whenever something stupid interesting happens. This saves a ton of work, as we'd otherwise have to watch for events ourselves so we can run the proper code when they came in. Of course this is just a convenience, but it should give us a hint: It doesn't have to be our responsibility to call a function! We can always give a function to someone else, and they can call it for us when the time is right.

Enter functors

Here's a functor:

function nullAware(func)
{
	// note: this returns another function!
	return function (value) {
		if (value !== null)  // look familiar?
			return func(value);
		else
			return null;
	};
}

A functor is like a "pipe fitting" for functions. What it does is to take some function, like our square function above, and make it work for values in a specific context (we'll call these "entangled values" from here on out), without the need to do anything meaningfully different at the call site compared to a standard function call.

let tryToSquare = nullAware(square);  // remember, our functor returns a function
console.log(tryToSquare(8));          // 64
console.log(tryToSquare(null));       // null

So now we have a way to call functions within the context of "the input value might not exist", without the need to check whether it exists ourselves. We can even chain these together just like we can chain together regular functions (i.e. passing the output of one as the input of the next), and the whole thing will automatically short-circuit the moment a single null is encountered, because the functor has handled that for us. And we never even had to check for null once!

An old friend

If you're a JavaScript developer, there's another functor you may be familiar with which is built right into the language: Array.prototype.map. It works as a "pipe fitting" in the same way as our nullAware functor above, taking a normal function and applying it to every element in the array for us. There's a subtle difference from nullAware in that calling .map() automatically does its thing and returns a result (eager evaluation), but the underlying concept is nonetheless the same:

let cube = x => x * x * x;      // ES6+ arrow function, just because I can
let list = [ 1, 2, 3, 4, 5 ];   // put some numbers into an array
let cubeList = list.map(cube);  // look ma, no loops!
console.log(cubeList);          // [ 1, 8, 27, 64, 125 ]

So this provides us with a way to call functions within the context of "there are actually multiple input values and they are part of a list". And since we can always delegate by passing a function off to someone else, it's always possible to write a functor like this, regardless of what the context is, and we never have to navigate that context ourselves again!

On a side note: This whole "context" business is all pretty abstract, isn't it? Yeah, that's why the box metaphor is useless as a teaching tool. We don't actually have things in neat little boxes yet, which is the problem we're trying to solve in the first place! (even if we don't realize it yet)

Hit me wit' da rules

A well-behaved functor must follow a few rules. There are rigorous mathematical definitions of these rules, but here they are in plain English:

  1. A functor supplied with an identity function x => x (i.e. a function which outputs exactly what was given as input) must remain an identity function within the context of the functor. For example, array.map(x => x) always returns an array containing the same values as the original array.
  2. Two applications of the same functor with different functions, when chained together from outside, must produce the same output as chaining those same functions from inside. In other words, array.map(square).map(cube) is exactly equivalent to array.map(x => cube(square(x))).

Or to put it even more simply: It doesn't matter whether we call the target function(s) on the values ourselves (assuming we properly navigated the context), or if the functor calls it for us. The outcome is the same.

A map by any other name

So a functor lets us take a context-free function and turn it into one which is context-aware. In more technical terms, we're said to have mapped the function over the context; from here on I will refer to this operation simply as map. In this way, we no longer have to manually write two (or more!) versions of each function we want to use this way. This already gives us a great deal of power, but what if the function we want to map itself returns an entangled value? Here are some examples:

  • Two or more I/O functions used in sequence, any of which may fail and return null to indicate their failure, instead of a meaningful value. How dare they!
  • Replacing each value in an array with multiple values, which requires the mapping function to return an array (as a function can only have a single return value).
  • A value you don't actually have access to right now (e.g. JavaScript Promise), which forces every further computation to be deferred and therefore you can never escape the Promise context--every subsequent step in the chain must also be represented by a Promise (note: Promise is itself very monad-like, though it does break the rules in the name of convenience; we'll see this later).

If we use a standard functor in situations like this, we usually end up with values which are doubly entangled. For example, mapping each item of an array to two items by returning an array from the map function will produce an array of arrays. This is not usually what we want. To solve this problem, what we need is an operation, let's call it flatten, that disentangles the value(s) from the "inner" context after a map and puts them back into the "outer" one. In most cases map and flatten are combined into a single operation, typically called flatMap or bind, though it can have different names depending on the specific context. For example, JavaScript's Promise has .then.

It turns out that, if we have the ability to both flatten and map over some context, we're already at least two thirds of the way there to having a monad. A monad is an abstract mathematical concept with some scary-looking rules and identities, but in plain English it consists of three basic components:

  • The ability to plant a value into some context: in other words, to create an entangled value at will. For example, we can readily stuff something into a new array, or set some variable to null with the understanding it'll be checked for later. This is a basic operation of monads and is sometimes called weird things like pure, return (no relation to the return values of functions!), or unit. We'll call it entangle.
  • A flatten operation over the context to resolve double entanglement, as described above.
  • A special functor, we'll call it thru4, to map functions over that context, with two additional caveats: 1. The mapped function should only return similiarly entangled values (we'll see why this is in a bit), and 2. The functor must flatten the context after the mapping operation is complete.

An important thing to note here that is left out of basically all monad tutorials is that none of this says anything about the structure of your program. It is too abstract a concept to do so. If you have the three things above, you already have a monad. The functor and flatten operation don't even have to be concrete things: they can merely exist "in spirit", for example as the general form of those for loops you keep writing!5 All your programs are already lousy with monads and you just didn't know it; they've been hiding in plain sight the whole time! It's all a bunch of abstract nonsense, but useful abstract nonsense because recognizing that this pattern exists will allow us to formalize it into something more concrete.

Monads: It's the law

As with functors above, there are a few laws a monad must follow to actually qualify as one, mathematically speaking:

  1. Creating an entangled value and using the special monadic functor (thru, if you've forgotten) to map some function over it is exactly the same as calling that function on the value directly. This is called the left identity: (entangle v) thru func === func(v)
  2. Using thru to map entangle over some existing context/value entanglement ([c+v]) always produces the original value(s) entangled in the same way. This is called the right identity: [c+v] thru entangle === [c+v]
  3. Chaining thru from outside is the same as chaining from inside. In other words, the associative property: [c+v] thru func1 thru func2 === [c+v] thru (v => func1(v) thru func2). Note that this depends on the left identity law being true, so if that law is broken, this will be too.

Promise breaks both Rules 1 and 3, because the value(s) used as input to a monadic context may themselves be entangled (this is another thing monad tutorials don't tell you!) and the language deliberately prevents you from creating a doubly-entangled promise value (i.e. a promise for a promise). This is a fair tradeoff in the name of convenience, I think: a promise for a promise is kind of a cruel joke, as it--at best--represents a scheduling conflict--"I'm booked right now, but sign up to be notified when I have an opening and then I can schedule you to get the value." But it does make Promise explicitly not a monad (in the mathematical sense), so this is something to keep in mind.

Incidentally, our example "variable that may be null" context is not a monad either, and breaks the rules in the same way Promise does. Can you guess why?

🎶 Jeopardy theme song plays 🎶

Give up? It's because there is only one null value! [value or null] or null can't be encoded this way, and therefore both Rules 1 and 3 are broken for the set of null values (i.e. null itself).

Turning abstractions into concretions

So by now you've probably figured out what functors buy us (note that a monad is just a special case of functor): they let us consider the combination of a context with the value(s) it contains as a single atomic value in its own right. At least, we can do this as a mental exercise. That's valuable, but can we take advantage of this in our code? Yes, we can!

Remember how I said the box metaphor was an awful teaching tool for introducing the concept of monads, but made a good mnemonic device? Yeah, well, now that we know what defines an (abstract) monad and can recognize the distinction between values and the context they exist within, we can define our own interchangeable "smart boxes" to encapsulate those contexts! Such a "smart box" might consist of the following standard components (assuming JavaScript class syntax):

  • A way to get a value (or another box!) into a brand-new box. Since we'll be using the class syntax, we can do this in the constructor. This will be the concrete realization of our monad's entangle operator (see above if you've forgotten).
  • A thru instance method defined on the box class to map functions over the box that themselves return values in the same type of box, allowing us to chain usages of thru. This, as it turns out, represents the monad's thru operator. Will wonders never cease?
  • Optional map and flat methods, if it makes sense to use these with our type of box. Mathematically, a monad is a kind of functor so it must support both map and flatten, but the mathematical definition only covers the abstract context itself; we don't have to split them up if it doesn't make sense for our program to use them separately. That's the beauty of it all: we're not creating monads here, we're just taking advantage of the fact that they exist!

All Boxed up

For our first example, we have here a class representing a context that always contains exactly one value:

class Box
{
	constructor(value) {
		// box it up! (this is our `entangle`)
		this.value = value;
	}
	flat() {
		// note: mathematically, `flatten` is undefined if the boxed value isn't another box.
		//       (because we must return exactly one value but there might be zero or more!)
		if (this.value instanceof Box)
			return this.value;
		else
			throw TypeError("It's already flat!");
	}
	map(func) {
		// `map` is our functor, it's like the CPU of our smart box!
		return new Box(func(this.value));
	}
	thru(func) {
		// per the left identity, we *should* just be able to call `func()` directly and be done
		// with it, but then we'd need static typing to stop evil box-unpackers!
		return this.map(func).flat();
	}
}

So now we have our box, and with it, can do things like:

let stupidBox = new Box(812);
stupidBox.map(x => console.log(x));  // prints 812 to the console
// (and also produces a box containing `undefined` but we're litterbugs so we discard it)

But this is kind of a stupid box, isn't it? By putting anything in this box, all we've done is to take a completely context-free value and entangle it with a context whose functor just does what we can already do ourselves with equally many lines of code (i.e. one). This is what makes the box metaphor so useless for introducing the concepts; to wit, why would I make things harder for myself by putting a thing in a box? Of course, now we know that the boxes (contexts) already existed--we just weren't able to recognize them until now.

Furthermore--and I really want to stress this now, before we go any further down the monadic rabbit hole--this class gains absolutely nothing by being monadic. Because the box always contains exactly one value and carries no additional information, literally everything meaningful we can do with thru can be done just as well with map. I'm not confident we have enough context for me to explain this adequately right now; however, it should become clearer with the next two examples.

So all told, this first attempt isn't very practical--but it does show us a basic design for a "smart box", and now we can use this outline to make more useful boxes. If we follow this design to encapsulate different contexts, we'll know exactly what to do when receiving any kind of box we want to operate on as if it were a single unit--just call its .thru() or .map() method and supply the appropriate function!

Maybe, but Maybe not

Remember how I said that "variable that may or may not be null" is not a proper monad? Let's make a version that is. An operation that produces a Maybe box like the one I'm about to show you is saying that it may either succeed and return some value, or fail and return nothing (because it failed--there's nothing to return).

class Maybe
{
	constructor(haveValue, value) {
		// `value` is ignored if `haveValue` is false
		this.haveValue = haveValue;
		this.value = value;
	}
	flat() {
		if (this.haveValue && this.value instanceof Maybe)
			return this.value;
		else
			throw TypeError("It's already flat!");
	}
	map(func) {
		if (this.haveValue)
			return new Maybe(true, func(this.value));
		else
			return new Maybe(false);
	}
	thru(func) {
		// remember: `thru` is just "map then flatten"
		return this.map(func).flat();
	}
}

Unlike our possibly null variable, this class represents a proper monadic context because we can readily nest Maybe objects. The ability to nest a context within the same context might sound useless--and in a real program it often is (see Promise)--but by definition, thru is the same as map followed by flatten, and flatten isn't defined for already-flat contexts (it's kind of like dividing by zero). So we must have the ability to encode double entanglement or we don't really have a monad.

So anyway, just like our Box class from before, with this one we can do the same kind of stuff:

let lessStupidBox = new Maybe(true, 8);         // it starts out containing 8
lessStupidBox = lessStupidBox.map(x => x * 2);  // now it contains 16
lessStupidBox = lessStupidBox.map(x => x * 3);  // now it contains 48

And now we can map our regular old functions over the Maybe box without having to manually disentangle the value from its context of "it possibly doesn't exist". Not a single if statement in sight! Very convenient! So here's a question: What would it mean to map a function over a context that doesn't contain anything? Hmm... well, if you put nothing into a pipeline, you get nothing out, so mapping any function over an empty Maybe box should just result in the function not being called, no harm done, and you get the empty box back:

let nil = new Maybe(false);  // whoops, something stupid happened, here's an empty box
nil = nil.map(x => x * 3);   // function not called, still an empty box

Our program doesn't crash, doesn't even produce garbage data (such as NaN values), and there's not a single null check to be seen for miles. The functor encapsulated by the Maybe class handled it all for us!

thru it all

So you may have spotted a problem here: if Maybe is considered only as a standard functor context (i.e. one in which the only available operator is map) containing some value, then there'd be no way for us to remove that value; we can only transform it. We can't turn a "something" into a "nothing", or in more general terms, we can't change the shape of the box. So if we wanted to chain some Maybe-returning operations together with the convention that the first empty Maybe means failure, we couldn't actually do that because the Maybe box, once initialized, will always have something in it.

Or can we? As it turns out, thanks to the entangle and flatten operators of the monad, we can! Behold:

// start with a new `Maybe` box containing 9000.
let box = new Maybe(true, 9000);

box = box.map(x => x + 1);
// now it's over 9000...

// reminder (again): `thru` is just "map then flatten"
box = box.thru(() => new Maybe(false));

// ...and now it's empty!

Keep in mind that a monadic box, once emptied, will always be empty (unless we go off the rails and mutate the box ourselves), but this is just a mathematical truth we have to deal with. Mapping a function over nothing always produces nothing, in the same way that a long sequential chain of multiplications, once it encounters a single zero, is doomed to produce zero as its result.6 In practice this isn't a problem when working with monads, because we can always use entangle to put something in a new box and start over.

But Maybe there's a problem

So now that we've learned what a monad is and implemented both Box and Maybe monadic classes ourselves, let's step back for a minute. Monad tutorials in general tend to lead off with a Maybe as their first exercise; I chose to lead instead with an obviously useless box. There's a good reason for this: to a seasoned imperative or OOP programmer, Maybe tends to look like a pointless abstraction, as while you save a line of code for every null check, it tends to add runtime overhead in the form of extra function calls for computations you would normally just do inline (I don't think functional programmers fully understand this--and why should they when functions, not statements and expressions, are the fundamental building blocks in their world--but it was a pretty big mental block for me at least). In my experience, the whole exercise ends up being skimmed over and then, because the tutorials rarely go into the theory to any meaningful depth, there's no intuition to build off of for understanding the more complex examples. Starting with an openly and deliberately worthless example gets rid of the distraction to focus only on the structure of the thing.

This is also why I chose to give an example of a functor earlier on in the form of Array.prototype.map, because this is a more practical application of the concept and is built right into the language. Developers who've used JavaScript for any significant length of time do tend to understand what map does and why it's useful, so it's a great jumping-off point.

Of course, I'm assuming here. Hopefully I'm right about all this and you've been able to follow me this far! The Great Curse is a tough one, but hopefully I've managed to overcome it. Anyway, back to our boxes!

Does not compute!

So now we've made a useless Box class, and a rather less useless Maybe class. Both of those were pretty boring though, so let's make something more interesting.

Maybe as a concrete representation of potential failure has this small issue that, if an error occurs at any point, we only get an empty box as output. We can't do anything with an empty box (unless, again, we cheat and poke at the Maybe object manually), so the process may silently abort midstream and we won't even know it. How can we solve this? Checking the return value of the individual functions manually and using throw doesn't count--we want to build on what we already have with Maybe.

Pick a color, Either color

Introducing Either, a type of box which always contains a single value and can be either Red (to indicate an error) or Green (to indicate success). This box encapsulates the abstract concept of "some part of the process may have failed and, if so, it aborted and produced an error value" (that was a mouthful, wasn't it?). Here it is in all its glory:

class Either
{
	constructor(isGreen, value) {
		this.isGreen = isGreen;
		this.value = value;
	}
	catch(func) {
		if (!this.isGreen)  // box is red, map over it (actually `thru`)
			return func(this.value);
		else  // green box, no error, pass on the value
		    return new Either(true, this.value);
	}
	flat() {
		if (this.value instanceof Either)
			return this.value;
		else
			throw TypeError("It's already flat!");
	}
	map(func) {
		if (this.isGreen)  // box is green, map over it
			return new Either(true, func(this.value));
		else  // red box, just pass on the error value
			return new Either(false, this.value);
		}
	}
	thru(func) {
		// third verse, same as the first.
		return this.map(func).flat();
	}
}

This works very much like our Maybe above, but there's two important new wrinkles to note: 1. map, when applied over a red box, simply passes on the error value without calling the mapped function (leaving the box and its color unchanged); and 2. There's a brand-new catch method! catch works like thru, but only calls the mapped function if the box is red. If it's green, it passes on the value--in other words, the exact opposite of thru. It's like having two monads in one!7

Also similarly to Maybe, if all we use is map, we can only change the value stored in the box, but not the box's color. If we need to change a green box to red, we have to use thru in combination with new Either(). So now we can do stuff like this:

openFile("file.txt")
	.thru(file => readLine())
	.map(line => console.log(line))
	.catch(error => console.log(`Oops! ${error}`));

Because red boxes simply have their error value passed through without applying the function, if either openFile or readLine fail, then the box turns red, the operation automatically aborts and the catch handler gets called at the end of the chain. Then you can find out exactly what went wrong. See how flexible these things are?

Monadic boxes like these can do all kinds of great things, but the basic underlying mechanics are always the same: you can either map over the box with map to operate on its current contents, or, if you need to create a new box in the process, use thru. That's all there is to it! If you know the value you have is a monadic box, regardless of which kind of box it is, you always know exactly what to do with it! It's an amazing abstraction to have under your belt.

As an aside: You may have noticed that the way this pair of .thru() and .catch() methods works is very similar to the pair of .then() and .catch() provided by JavaScript for Promise objects. This is no coincidence; while Promise breaks the rules and therefore disqualifies itself from being a true monad, it's built on the exact same concepts as our Either monad here. Something to think about!

I seem to be lacking context

Now that we've gotten this far, I wanted to point out something interesting before I close out the tutorial. I've used the term "context-free" a few times to refer to values which are not entangled with any context--meaning you can pass them directly to one of your functions without needing to do anything else before you use the value. It's a value with no strings attached, right? Want to hear something wild? This state of being context-free is itself a monadic context!8 The entire value space can be thought of as a kind of "null context" in which entangle is the identity function, map is the identity functor (i.e. it simply calls the mapped function with the value), and flatten is a no-op (no nesting, infinite nesting, it's all the same).

We can even do something really crazy and turn literally the entire value space (except undefined and null for reasons) into one of our monadic smart boxes:

// trained professional on a closed course.  do not attempt!
Object.prototype.flat = function () { return this; };
Object.prototype.map = function (func) { return func(this); };
Object.prototype.thru = function (func) { return this.map(func).flat(); };

// no implementation for `entangle` because:
//   1. it's an identity function
//   2. there's no good place to put it
//   3. we can just poof up an *ahem* "entangled" value anyway
console.log((812).thru(x => x * 2));  // 1624

Okay, so maybe I'm stretching the definition of "smart" to its breaking point but... pretty crazy, huh? You can even plug in all the monad laws and it'll all check out!

Conclusion

So now, hopefully, the Great Curse has been well and truly broken and you fully understand both what a monad is in the abstract sense (having been hiding in plain sight all along!), as well as how to design concrete types of monadic boxes that take advantage of their existence. Values are intrinsically entangled with the contexts they exist within, that's true, but there's no reason you can't make the context itself do the work of disentangling them for you. Nobody really wants to write the same few lines of code over and over and over again, and now, thanks to monads, you don't always have to! Have fun in your new monadic adventures!

But, um... Maybe be careful of opening that pig dimension? Nothing good can come of that, I assure you. 🐗

Footnotes

  1. This famous quote is originally from http://james-iry.blogspot.com/2009/05/brief-incomplete-and-mostly-wrong.html, fictionally attributed to Philip Wadler. It was meant as a joke: the joke being that it's 100% mathematically accurate, but also 100% useless for actually teaching anyone about monads.

  2. Oh sure, people think an elephant in the room would make a great conversation starter. Then you accidentally offend it and get trampled to death. Not so fun to talk about anymore, is it? 🐘

  3. I believe this tends to be referred to in stuffed-shirt circles as "undefined behavior". Trust me, it's always pigs. Every single time.

  4. I had a really hard time choosing a non-scary name for this one that could apply more or less equally to all kinds of monads. A lot of people go for chain, but what would it mean to chain lists, for example? That just sounds like concatenation, which is misleading. thru is better mnemonically, as it directly communicates our intent: rather than mapping over the monadic context, we're mapping through it, creating a new context in the process.

  5. In exactly the same sense that you could write a for loop that adds y to itself x times; in the end you've still multiplied x * y, you just made it harder for yourself (and the computer!).

  6. Unless there's also an ∞ Infinity involved. Then it gets complicated.

  7. This construct, besides being a monad, is also sometimes called a bifunctor, as there are two separate "compartments" and each one can be mapped over independently. That said, the compartments of Either are not completely independent; the box is only ever red or green--never both. It's entirely possible to create a version that does accept two values, but that's left as an exercise for the reader.

  8. It also happens to be a comonadic context, but let's not go down that rabbit hole! 🐇

@darrylnoakes
Copy link

One thing I found with your Maybe implementation: I cannot chain a .thru() onto an empty Maybe.
Surely this should be possible?

Example:

let foo = new Maybe(true, 0.5)
foo
  .map(x => x * 2)
  .thru(x => x > 1 ? new Maybe(true, x) : new Maybe(false))
  .thru(x => x % 2 === 0 ? new Maybe(true, x) : new Maybe(false))
  .map(x => console.log(x))

(This is because the function passed to .thru() returns a Maybe, but .map() may or may not use that function and so its result could be one or two layers of Maybes; .flat() then fails if it is only one layer.)

The implementation of .join() (flatten) in Dr. Frisby's Mostly Adequate Guide returns an empty Maybe from flat if the target Maybe is empty:

Maybe.prototype.join = function join() {
  return this.isNothing() ? Maybe.of(null) : this.$value;
};

In your Maybe (I think this is correct):

flat() {
  if (this.haveValue && this.value instanceof Maybe)
    return this.value;
  else
    return new Maybe(false);
}

I do not know if this is mathematically correct, of course.
The one from The Guide would return an unwrapped value if called on a non-nested Maybe. You just have to use it safely; of course, that applies to both, as your implementation throws an error.
My version of your implementation would return an empty Maybe.


I would dearly like it if you continued on to ap and liftA2...
And maybe pointfree as a bonus...


Aside:
The names I have often seen for constructing a Monad are point and of.

@fatcerberus
Copy link
Author

@darrylnoakes
So this was written when I first started to grok the concept of monads--and long before I started down the rabbit hole that is category theory--but IMO it still holds up better than most monad tutorials (burritos are fun, but not much help for explaining the abstractions themselves). I can hopefully now explain this part better: basically, mathematically speaking, it doesn't make sense to flatten a non-nested box because flatten is actually a binary operation (that's where the "monoid in the category of functors" definition comes in: the monoid in question is just the multiplication of wrappers!), it only looks unary because we traditionally model composition with an intermediate step where the boxes are nested inside one another.

I don't doubt there are bugs in the tutorial code as this was thrown together pretty quickly; I should probably check it all over sometime.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment