Better Error Handling with Monads (Part 2)
The first part of this article explained monads and wrote vanilla JS code for them. Now, this second part answers the big question: What are monads good for, and why should you use them?
Discover how at OpenReplay.com.
Monads have several uses, so let’s start by showing how to deal with “the billion dollar mistake”, mishandling null values, in a very simple way.
Null values: “The Billion Dollar Mistake”
Anthony Hoare, the inventor of Quicksort, spoke a few years ago about his “invention” of null references:
I call it my billion-dollar mistake. It was the invention of the null reference in 1965. At that time, I was designing the first comprehensive type system for references in an object-oriented language (ALGOL W). My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.
Given the unending list of bugs that have occurred because of null pointers, missing values, undefined results, and the like, it seems that any solution that helps deal more carefully with this kind of situation would be welcome! It happens that monads do allow such a workaround; let’s introduce the Maybe
monad and see how the problem practically goes away with very simple code.
All told, a monad in X is just a monoid in the category of endofunctors of X, with product × replaced by composition of endofunctors and unit set by the identity endofunctor. Saunders Mac Lane
Dealing with missing values: the MAYBE monad
Let’s see our first example. Assume we have a value that may or may not be present. A Maybe
can be two different monads:
- A
Just
(“just a value”) when the value is present - A
Nothing
when the value is absent (so it is null or undefined)
Let’s first see Maybe
, which actually is a factory that will produce either Just
or Nothing
monads.
function Maybe(x) {
isEmpty(x) // ➊
? Nothing.call(this) // ➋
: Just.call(this, x); // ➌
}
The isEmpty()
function ➊ checks if the passed value is null or undefined; I’ll leave it up to you to code it. When we create a Maybe
, depending on the value we pass to it, a Nothing
➋ or a Just
➌ will be created.
Let’s see the other two monads we need, starting with Just
.
function Just(x) {
Monad.call(this, x); // ➊
this.isNothing = () => false; // ➋
this.map = (fn) => // ➌
new Maybe(fn(x));
this.chain = (fn) => // ➍
new Maybe(unwrap(fn(x)));
}
Since Just
extends Monad
, we start ➊ by calling the required constructor. We are adding an isNothing()
method ➋, so given a Maybe
, we can test if it is a Nothing
or not; for a Just
, isNothing()
returns false. The map()
➌ and chain()
➍ methods are similar to what we saw earlier; the difference is that after mapping or chaining, a Maybe
is returned instead of a generic Monad
.
We can now look at the Nothing
monad.
function Nothing() {
Monad.call(this);
this.isNothing = () => true; // ➊
this.map = this.chain = () => this; // ➋
this.toString= ()=> "Nothing()"; // ➌
}
Let’s just highlight the differences with Just
. The .isNothing()
function here returns true. The .map()
and .chain()
methods ➋ do nothing at all; they just return the monad itself, ignoring whatever arguments you pass to them. Finally ➌ we must fix the .toString()
method because Nothing
monads contain no values.
How would you use a Maybe
? The idea is that you get a value, construct a Maybe
, and then just chain operations. If the value isn’t empty, the operations will be performed, but otherwise, they will be ignored. The following example shows a simple case. Note we are using pointfree notation:
const plus1 = (x) => x + 1; // ➊
const times2 = (x) => 2 * x;
new Maybe(100) // ➋
.map(plus1) // ➌
.map(times2) // ➍
.toString(); // ➎ Just(202)
new Maybe(null) // ➏
.map(plus1)
.map(times2)
.toString(); // ➐ Nothing()
First ➊ let’s define some simple functions to test our monads. If ➋ you build a Maybe
with a nonzero value (100) you can increment it by one ➌ and duplicate the result ➍ and you’ll end ➎ with a Just(202)
monad. (Note that we could have used .chain()
instead of .map()
and it would have produced the same result.) On the other hand, if you start with a null
➏ the operations are ignored, and you end ➐ with a Nothing
instead. You didn’t have to write any if
statements anywhere; the Maybe
, whether a Just
or a Nothing
works accordingly.
Note: If you remember it, this is the same we showed when we built our booleans out of functions! In that case, TRUE and FALSE were functions that dealt differently with whatever you passed to them; here,
Just
andNothing
work in the same way.
Dealing with default values
But let’s add to this! You would want to use a default value instead when dealing with possible missing values. You could use isNothing()
and write tests, but that wouldn’t use the power of monads! Instead, let’s add an orElse()
method to specify what value to use when you get a Nothing
. (Of course, orElse()
won’t do anything if you got a Just
.)
function Just(x) {
// ...all previous code, plus:
this.orElse = () => this; // ➊
}
function Nothing() {
// ...all previous code, plus:
this.orElse = (x) => new Maybe(x); // ➋
}
The new .orElse()
method ➊ won’t do anything for a’ Just’ monad. Conversely, for a Nothing
monad ➋ .orElse()
will produce a new Maybe
, most likely not a Nothing
. (Why would you pass an empty value to the .orElse()
method for a Nothing
? That would just produce a new Nothing
.)
We can now see examples dealing with missing and default values.
new Maybe(1900) // ➊
.orElse(50) // ➋
.map(plus1) // ➌
.toString(); // ➍ Just(1901)
new Maybe(null) // ➎
.orElse(50) // ➏
.map(plus1)
.toString(); // ➐ Just(51)
new Maybe(null) // ➑
.map(plus1)
.orElse(50) // ➒
.toString(); // ➓ Just(50)
In the first example ➊ we start with a Just(1900)
, so orElse()
doesn’t do anything ➋ and after incrementing the value by 1 ➌ we end with a Just(1901)
➍. The second case is more interesting; after starting with a Nothing
➎ the orElse()
produces a Just(50)
➏ which is then incremented, thus ending ➐ with a Just(51)
. The third case shows a variation; again, we start with a Nothing
, we then use map()
(but without any effect), and we apply orElse()
➒ so we finish ➓ with a Just(50)
; where you place the orElse()
makes a difference!
A realistic example
Let’s go with a real-life example. Assume that, given a customer’s id, we want to show what country he lives in. There are some issues, though:
- The customer id might be wrong
- The customer id could be correct, but he could not have an
address
field - The customer could have an
address
, but without acountryCode
field - The customer could have a
countryCode
field, but its value might be wrong
You would usually deal with this by writing many if
statements, but the solution is much simpler with Maybe
monads. Let’s assume that we have:
- a
getCustomer()
function that, given anid
, returns aJust
with the customer’s data (if the customer exists) or aNothing
otherwise - a
countryCodeToName()
function that, given a country code, returns the country name, orundefined
instead - a
getField()
function that, given an object and a field name, returns that field from the object, ornull
instead. We can easily codeconst getF = (field)=> (obj) => obj ? obj[field] : null;
for that.
Now, how can we display the customer’s country name or “N/A” instead? A single line does the job — though we will display in several lines for clarity:
getCustomer(22) // ➊
.map(getField("address")) // ➋
.map(getField("countryCode")) // ➌
.map(countryCodeToName) // ➍
.orElse("N/A") // ➎
.map(console.log); // ➏
Do you see how this works? If at any step an empty value is produced (when getting the customer with id 22 ➊ or getting its address
field ➋ or getting the countryCode
from the address ➌ or mapping the code to a name ➍) a Nothing
will be generated. If the customer exists and has all the needed fields with correct values, the name of his country will be displayed; otherwise, the alternative default "N/A"
value ➎ will be used. At the end ➏ either the country name or the default text will be shown. Note that the code is direct, declarative, and easy to follow!
Now that we’ve seen a complete example with Maybe
monads, let’s consider some extra monads for other cases.
Dealing with errors: the EITHER monad
When some operation produces an error, we may want more data about it. We’ll create an Either
monad, which can be a Left
(if there was an error) or a Right
(if no error was produced). For instance, a function doing an API call would return a Left
or Right
monad, depending on how the call went.
First, let’s look at Either
itself, which (as in the case of the Maybe
monad) is another factory that produces either Left
or Right
monads.
function Either(l, r) { // ➊
!isEmpty(l) // ➋
? Left.call(this, l) // ➌
: Right.call(this, r); // ➍
this.toString = // ➎
() => `Either(${l},${r})`;
}
To create an Either
, let’s use the “error-first” strategy that Node.js uses. If the first argument is not empty ➋ we assume there was an error, and produce a Left
➌; otherwise the second argument represents data and we use that to produce a Right
➍. We have to override the standard .toString()
method ➎ to produce a better description of the object.
Let’s get into the details. The Left
monad (see below) resembles the Nothing
one. Instead of a isNothing()
function ➊ we’ll have a isLeft()
one; we could have called it isError()
instead, but I wanted to keep the same naming style. The .map()
and .chain()
methods ➋ do nothing.
function Left(x) {
Monad.call(this, x);
this.isLeft = () => true; // ➊
this.map = this.chain = () => this; // ➋
}
On the other hand, Right
is similar to Just
. The .isLeft()
method logically returns false ➊. The .map()
method is more complex, so we’ll just show pseudocode ➋. We have to evaluate fn(x)
and check if it produced an error ➋ and, depending on that, return either a Left
or a Right
. The same type of logic ➌ applies to .chain()
.
function Right(x) {
Monad.call(this, x);
this.isLeft = () => false; // ➊
this.map = (fn) => // ➋
// call fn(x) and store it in v
// if fn(x) produced an error e,
// return new Left(e)
// otherwise return new Right(v)
this.chain = (fn) => // ➌
// similar to .map() but unwrap v
// when creating the new Right
}
If you are coding a Node-style callback, which expects error
and data
parameters, producing an Either
is straightforward, along the lines of the code below. Note that you would surely do something with the new Either
, not just return it:
function someCallback(error, data) {
return new Either(error, data)
// do some operations with the Either
}
Working with Either
is very much the same as with Maybe
; the only difference is that when you got a Left
you can check its value (with .valueOf()
) and use that for whatever you want. Other than that, Left
mirrors Nothing
, and Right
mirrors Just
.
Recovering from errors
How can we recover from issues? We can add a .recover()
method; it will do whatever we need when there is an error (when we have a Left
) but nothing otherwise (when we have a Right
). With Maybe
, we wanted to pass a default value to use; here, we had to pass a function to deal with the error. The extra code is as follows:
function Left(x) {
// ...all previous code, plus:
this.recover = (fn) => // ➊
// evaluate fn() and store it in v // ➋
// if it produces an error e,
// return new Left(e)
// otherwise return new Right(v)
}
function Right(x) {
// ...all previous code, plus:
this.recover = () => this; // ➌
}
If you got a Left
, .recover()
will invoke your function ➊ and produce a new Left
or Right
➋ based on whatever the function does. Using .recover()
with a Right
➌ produces no effect.
Dealing with exceptions: the TRY monad
We can go further with the previous Either
monad. Consider a function that might throw an exception. We can use the Try
monad to produce an Either
(a Left
or a Right
) as before. The needed code is shown below:
function Try(fn) {
try {
Right.call(this, fn()); // ➊
} catch (e) {
Left.call(this, e.message); // ➋
}
}
// Or, equivalently:
function Try(fn) {
try {
Either.call(this, null, fn());
} catch (e) {
Either.call(this, e.message, null);
}
}
Logic is very simple; we call the fn()
function, and if there’s no exception ➊ we return a Right
with whatever the function returned, and if an exception e
was thrown ➋ we return a Left
with the exception. The second implementation does the same, albeit a tad more indirectly.
Usage is straightforward: We could just rewrite our previous example, which showed a customer’s country, by having getCustomer()
return a Try
; no further change would be needed.
Dealing with future values: Promises
We’ve left the simplest example for the end: promises! It so happens that JavaScript’s own promises are already pretty much like monads. We can make do by just modifying Promise.prototype
a bit:
Promise.prototype.map =
Promise.prototype.chain =
Promise.prototype.then; // ➊
Promise.prototype.ap = // ➋
function(otherPromise) {
return this.map(otherPromise.map);
};
There’s an issue; promises always unwrap their values (so if you have a promise that resolves to a promise that resolves to a value, you get just one promise that resolves to a value, not a promise wrapped in another promise, according to the standard specification) so .map()
and .chain()
behave the same way, as a synonym for .then()
.
We can add an .ap()
method with ease ➋ and the code is equivalent to what we saw in the previous article for monads; check it out.
Conclusion
Over these two articles, we have seen what monads are and how they help write simpler, more declarative code. Considering how monads are sometimes explained, a crucial detail is that just focusing on HOW they are built instead of WHAT they are suitable for actually misses the point of having monads at all. The implementation of monads isn’t that complex —we only needed a few lines of vanilla JS— so you can now start taking advantage of them for your own work; go ahead!
Further Reading
When you start working with monads, error handling naturally leads you to “Railway Oriented Programming” (see video here); read more on this!
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.