Better Error Handling with Monads (Part 2)

OpenReplay Tech Blog - Sep 16 - - Dev Community

by Federico Kereki

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?

Session Replay for Developers

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — an open-source session replay suite for developers. It can be self-hosted in minutes, giving you complete control over your customer data.

OpenReplay

Happy debugging! Try using OpenReplay today.


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);     // ➌
}
Enter fullscreen mode Exit fullscreen mode

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)));
}
Enter fullscreen mode Exit fullscreen mode

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()";     // ➌
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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 and Nothing 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); // ➋
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 a countryCode 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 an id, returns a Just with the customer's data (if the customer exists) or a Nothing otherwise
  • a countryCodeToName() function that, given a country code, returns the country name, or undefined instead
  • a getField() function that, given an object and a field name, returns that field from the object, or null instead. We can easily code const 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);            // ➏
Enter fullscreen mode Exit fullscreen mode

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})`;
}
Enter fullscreen mode Exit fullscreen mode

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; // ➋
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;           // ➌
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  };
Enter fullscreen mode Exit fullscreen mode

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!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player