📜 ⬆️ ⬇️

Elegant JavaScript error handling with the Either monad

Let's talk a little about how we handle errors. In JavaScript, we have a built-in language function for dealing with exceptions. We enclose the problem code in a try...catch construct. This allows you to register a normal execution path in the try section, and then deal with all the exceptions in the catch section. Not a bad option. This allows you to focus on the current task, without thinking about every possible error. Definitely better than littering the code with endless if.

Without try...catch it is difficult to check the results of each function call for unexpected values. This is a useful design. But she has some problems. And this is not the only way to handle errors. In the article we will discuss the use of the Either monad as an alternative to try...catch .

Before continuing, I will note a couple of points. The article assumes that you already know about the composition of functions and currying. And a warning. If you haven't experienced monads before, they may seem really ... strange. Working with such tools requires a change in thinking. At first it is hard.
')
Do not worry if you are immediately confused. All so. At the end of the article I have listed several links that may help. Do not give up. These things are intoxicating as they penetrate the brain.

Example problem


Before discussing the problems of exceptions, let's talk about why they exist at all and why try...catch blocks have appeared. To do this, look at the problem that I tried to make at least partly realistic. Imagine that we are writing a function to display a list of notifications. We have already managed (somehow) to return data from the server. But for some reason, the backend engineers decided to send it in CSV format, not JSON. Raw data may look something like this:

  timestamp, content, viewed, href
 2018-10-27T05: 33: 34 + 00: 00, @ madhatter, https: //example.com/invite/tea/3801
 2018-10-26T13: 47: 12 + 00: 00, @ queenofhearts mentioned in the Croquet Tournament 'discussion, viewed, https: //example.com/discussions/croquet/1168
 2018-10-25T03: 50: 08 + 00: 00, @ cheshirecat sent you a grin, unread, https: //example.com/interactions/grin/88 

We want to display it in HTML. It might look something like this:

 <ul class="MessageList"> <li class="Message Message--viewed"> <a href="https://example.com/invite/tea/3801" class="Message-link">@madhatter invited you to tea</a> <time datetime="2018-10-27T05:33:34+00:00">27 October 2018</time> <li> <li class="Message Message--viewed"> <a href="https://example.com/discussions/croquet/1168" class="Message-link">@queenofhearts mentioned you in 'Croquet Tournament' discussion</a> <time datetime="2018-10-26T13:47:12+00:00">26 October 2018</time> </li> <li class="Message Message--viewed"> <a href="https://example.com/interactions/grin/88" class="Message-link">@cheshirecat sent you a grin</a> <time datetime="2018-10-25T03:50:08+00:00">25 October 2018</time> </li> </ul> 

To simplify the task, for now let's just focus on processing each row of CSV data. Let's start with a few simple functions for handling strings. The first divides the text string into the fields:

 function splitFields(row) { return row.split('","'); } 

Here the function is simplified, because it is educational material. We deal with error handling, not CSV analysis. If one of the messages gets a comma, it will all be terribly wrong. Please never use such code to analyze real CSV data. If you have ever had to analyze CSV data, use a well-tested CSV parsing library .

After splitting the data, we want to create an object. And so that each property name matches the CSV headers. Suppose we have somehow analyzed the header line (more on that later). We have reached a point where something can go wrong. We got an error to handle. We give an error if the length of the string does not match the header line. ( _.zipObject is a lodash function ).

 function zipRow(headerFields, fieldData) { if (headerFields.length !== fieldData.length) { throw new Error("Row has an unexpected number of fields"); } return _.zipObject(headerFields, fieldData); } 

After that, add a human-readable date to the object to display it in our template. It turned out a bit verbose, as in JavaScript there is no ideal built-in support for formatting dates. And again we face potential problems. If an invalid date is encountered, our function gives an error.

 function addDateStr(messageObj) { const errMsg = 'Unable to parse date stamp in message object'; const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; const d = new Date(messageObj.datestamp); if (isNaN(d)) { throw new Error(errMsg); } const datestr = `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`; return {datestr, ...messageObj}; } 

Finally, we take an object and pass it through the template function to get an HTML string.

 const rowToMessage = _.template(`<li class="Message Message--<%= viewed %>"> <a href="<%= href %>" class="Message-link"><%= content %></a> <time datetime="<%= datestamp %>"><%= datestr %></time> <li>`); 

It would also be nice to print an error if it met:

 const showError = _.template(`<li class="Error"><%= message %></li>`); 

When everything is in place, you can assemble a function to handle each line.

 function processRow(headerFieldNames, row) { try { fields = splitFields(row); rowObj = zipRow(headerFieldNames, fields); rowObjWithDate = addDateStr(rowObj); return rowToMessage(rowObj); } catch(e) { return showError(e); } } 

So, the function is ready. Let's take a closer look at how it manages exceptions.

Exceptions: the good part


So, what's good about try...catch ? It should be noted that in the example above, any of the steps in the try block may cause an error. In zipRow() and addDateStr() we intentionally throw errors. And if a problem occurs, just catch the error and show any message on the page. Without this mechanism, the code becomes really ugly. Here is how it might look. Suppose that functions do not throw errors, but return null .

 function processRowWithoutExceptions(headerFieldNames, row) { fields = splitFields(row); rowObj = zipRow(headerFieldNames, fields); if (rowObj === null) { return showError(new Error('Encountered a row with an unexpected number of items')); } rowObjWithDate = addDateStr(rowObj); if (rowObjWithDate === null) { return showError(new Error('Unable to parse date in row object')); } return rowToMessage(rowObj); } 

As you can see, a large number of template if expressions appeared. The code is more verbose. And it is difficult to follow the basic logic. In addition, the null value doesn't tell us much. We do not really know why the previous function call failed. We have to guess. We create an error message and call showError() . This code is dirtier and more confusing.

Look again at the exception handling version. It clearly separates the successful path of the program and the exception handling code. The try branch is a successful path, and catch is an error. All exception handling occurs in one place. And individual functions may report why they failed. Overall, this seems pretty sweet. I think that most consider the first example quite appropriate. Why another approach?

Problems with exception handling try ... catch


This approach allows you to ignore these annoying errors. Unfortunately, try...catch does its job too well. You simply throw an exception and move on. We can catch it later. And everyone intends to always put such blocks, really. But it is not always obvious where the error goes further. And the block is too easy to forget. And before you understand it, your application crashes.

In addition, exceptions pollute the code. We will not discuss functional purity in detail here. But let's consider one small aspect of functional purity: referential transparency. A reference-transparent function always returns the same result for a particular input. But for functions with exceptions, we cannot say that. At any time, they can throw an exception instead of returning a value. This complicates the logic. But what if finding a win-win option is a clean way to handle errors?

We come up with an alternative


Pure functions always return a value (even if that value is missing). Therefore, our error handling code must assume that we always return a value. So, as a first attempt, what to do if, in the event of a failure, we returned an Error object? That is, wherever we get an error, we return such an object. It might look something like this:

 function processRowReturningErrors(headerFieldNames, row) { fields = splitFields(row); rowObj = zipRow(headerFieldNames, fields); if (rowObj instanceof Error) { return showError(rowObj); } rowObjWithDate = addDateStr(rowObj); if (rowObjWithDate instanceof Error) { return showError(rowObjWithDate); } return rowToMessage(rowObj); } 

This is not a special improvement version without exceptions. But this is better. We have moved responsibility for error reporting back to individual functions. But we still have all these ifs. It would be nice to somehow encapsulate the template. In other words, if we know that we have a mistake, do not worry about executing the rest of the code.

Polymorphism


How to do it? This is a difficult problem. But it can be solved with the help of the magic of polymorphism . If you have not experienced polymorphism before, do not worry. In essence, this is “providing a single interface for entities of different types” (Straustrup, B. “Glossary of C ++ by Björn Straustrup”). In JavaScript, this means that we create objects with equally named methods and signatures. But different behavior. A classic example is application logging. We can send our magazines to different places depending on the environment in which we are located. What if we create two logger objects, for example?

 const consoleLogger = { log: function log(msg) { console.log('This is the console logger, logging:', msg); } }; const ajaxLogger = { log: function log(msg) { return fetch('https://example.com/logger', {method: 'POST', body: msg}); } }; 

Both objects define a log function that expects one string parameter. But they behave differently. The beauty is that we can write code that calls .log() , no matter which object it uses. This can be consoleLogger or ajaxLogger . Everything works anyway. For example, the code below will work equally well with any object:

 function log(logger, message) { logger.log(message); } 

Another example is the .toString() method for all JS objects. We can write a .toString() method for any class that we create. Next, you can create two classes that implement the .toString() method differently. Let's call them Left and Right (I'll explain the names a little later).

 class Left { constructor(val) { this._val = val; } toString() { const str = this._val.toString(); return `Left(${str})`; } } 

 class Right { constructor(val) { this._val = val; } toString() { const str = this._val.toString(); return `Right(${str})`; } } 

Now create a function that calls .toString() on these two objects:

 function trace(val) { console.log(val.toString()); return val; } trace(new Left('Hello world')); // ⦘ Left(Hello world) trace(new Right('Hello world')); // ⦘ Right(Hello world); 

Not outstanding code, I know. But the fact is that we have two different types of behavior that use the same interface. This is polymorphism. But pay attention to something interesting. How many if statements have we used? Zero. No one. We created two different types of behavior without a single if-operator. Perhaps something like this can be used for error handling ...

Left and Right


Returning to our problem. It is necessary to define a successful and unsuccessful way for our code. On a successful journey, we simply continue to quietly run the code until an error occurs or we finish. If we find ourselves on the wrong path, we will no longer try to run the code. We could call these paths Happy and Sad, but try to follow the naming conventions that other programming languages ​​and libraries use. So, let's call the unsuccessful path Left, and the successful one - Right.

Create a method that runs a function if we are on a successful path, but ignore it on an unsuccessful one:

 /** * Left represents the sad path. */ class Left { constructor(val) { this._val = val; } runFunctionOnlyOnHappyPath() { // Left is the sad path. Do nothing } toString() { const str = this._val.toString(); return `Left(${str})`; } } 

 /** * Right represents the happy path. */ class Right { constructor(val) { this._val = val; } runFunctionOnlyOnHappyPath(fn) { return fn(this._val); } toString() { const str = this._val.toString(); return `Right(${str})`; } } 

Something like this:

 const leftHello = new Left('Hello world'); const rightHello = new Right('Hello world'); leftHello.runFunctionOnlyOnHappyPath(trace); // does nothing rightHello.runFunctionOnlyOnHappyPath(trace); // ⦘ Hello world // ← "Hello world" 

Broadcast


We are approaching something useful, but not quite yet. Our .runFunctionOnlyOnHappyPath() method returns the _val property. Everything is fine, but too inconvenient if we want to run more than one function. Why? Because we no longer know, we are on a successful or unsuccessful path. The information disappears as soon as we take the value beyond Left and Right. So what we can do is return the Left or Right path with the new _val inside. And we will shorten the name, since we are here. What we do is the translation of a function from the world of simple values ​​into the world of Left and Right. Therefore, we call the map() method:

 /** * Left represents the sad path. */ class Left { constructor(val) { this._val = val; } map() { // Left is the sad path // so we do nothing return this; } toString() { const str = this._val.toString(); return `Left(${str})`; } } 

 /** * Right represents the happy path */ class Right { constructor(val) { this._val = val; } map(fn) { return new Right( fn(this._val) ); } toString() { const str = this._val.toString(); return `Right(${str})`; } } 

We insert this method and use Left or Right in the free syntax:

 const leftHello = new Left('Hello world'); const rightHello = new Right('Hello world'); const helloToGreetings = str => str.replace(/Hello/, 'Greetings,'); leftHello.map(helloToGreetings).map(trace); // Doesn't print any thing to the console // ← Left(Hello world) rightHello.map(helloToGreetings).map(trace); // ⦘ Greetings, world // ← Right(Greetings, world) 

We have created two paths of execution. We can put the data on a successful path by calling new Right() , or on a bad one by calling new Left() .


Each class represents a path: successful or unsuccessful. I stole this railway metaphor from Scott Vlaschina

If the map worked on a successful path, go through it and process the data. If we find ourselves unsuccessful, nothing will happen. Just keep passing the value on. If we, for example, put Error on this unfortunate path, we would have something very similar to try…catch .


Use .map() to move along the path.

As the path progresses, it becomes a bit difficult all the time to write Left or Right, so we will call this combination simply Either ("or"). Either left or right.

Labels for creating Either objects


So the next step is to rewrite our examples of functions so that they return to Either. Left for error or Right for value. But before we do this, let's have some fun. Let's write a couple of tags. The first is a static method called .of() . It only returns the new Left or Right. The code might look like this:

 Left.of = function of(x) { return new Left(x); }; Right.of = function of(x) { return new Right(x); }; 

Honestly, even Left.of() and Right.of() tiring to write. Therefore, I tend to shorter labels left() and right() :

 function left(x) { return Left.of(x); } function right(x) { return Right.of(x); } 

With these tags, let's start rewriting the functions of the application:

 function zipRow(headerFields, fieldData) { const lengthMatch = (headerFields.length == fieldData.length); return (!lengthMatch) ? left(new Error("Row has an unexpected number of fields")) : right(_.zipObject(headerFields, fieldData)); } function addDateStr(messageObj) { const errMsg = 'Unable to parse date stamp in message object'; const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; const d = new Date(messageObj.datestamp); if (isNaN(d)) { return left(new Error(errMsg)); } const datestr = `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`; return right({datestr, ...messageObj}); } 

Modified functions are not so different from the old ones. We simply wrap the return value in either Left or Right, depending on whether there is an error.

After that we can start processing the main function that processes one line. Let's start by placing the line in Either with right() , and then broadcast splitFields to split it:

 function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); // … } 

It works just fine, but trouble happens if you try to do the same with zipRow() :

  function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); const rowObj = fieldsEither.map(zipRow /* wait. this isn't right */); // ... } 

The fact is that zipRow() expects two parameters. But the functions that we pass to .map() get only one value from the ._val property. The situation can be corrected with the help of the zipRow() version. It might look something like this:

 function zipRow(headerFields) { return function zipRowWithHeaderFields(fieldData) { const lengthMatch = (headerFields.length == fieldData.length); return (!lengthMatch) ? left(new Error("Row has an unexpected number of fields")) : right(_.zipObject(headerFields, fieldData)); }; } 

This small change simplifies the zipRow conversion, so it will work well with .map() :

 function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); const rowObj = fieldsEither.map(zipRow(headerFields)); // ... But now we have another problem ... } 

Join


Using .map() to run splitFields() is fine, as .splitFields() does not return Either. But when you have to run zipRow() , a problem arises because it returns Either. So when using .map() we end up sticking to Either inside Either. If we go further, we'll get stuck until we run .map() inside .map() . That won't work either. We need some way to combine these nested eithers. So let's write a new method, which we call .join() :

 /** *Left represents the sad path. */ class Left { constructor(val) { this._val = val; } map() { // Left is the sad path // so we do nothing return this; } join() { // On the sad path, we don't // do anything with join return this; } toString() { const str = this._val.toString(); return `Left(${str})`; } } 

 /** * Right represents the happy path */ class Right { constructor(val) { this._val = val; } map(fn) { return new Right( fn(this._val) ); } join() { if ((this._val instanceof Left) || (this._val instanceof Right)) { return this._val; } return this; } toString() { const str = this._val.toString(); return `Right(${str})`; } } 

Now we can “unpack” our assets:

 function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); const rowObj = fieldsEither.map(zipRow(headerFields)).join(); const rowObjWithDate = rowObj.map(addDateStr).join(); // Slowly getting better... but what do we return? } 

Chain


We are far advanced. But you have to remember all the time to call .join() , which is annoying. However, we have a common sequential call pattern .map() and .join() , so let's create a quick access method for it. Let's call it chain() (chain), because it links together functions that return Left or Right.

 /** *Left represents the sad path. */ class Left { constructor(val) { this._val = val; } map() { // Left is the sad path // so we do nothing return this; } join() { // On the sad path, we don't // do anything with join return this; } chain() { // Boring sad path, // do nothing. return this; } toString() { const str = this._val.toString(); return `Left(${str})`; } } 

 /** * Right represents the happy path */ class Right { constructor(val) { this._val = val; } map(fn) { return new Right( fn(this._val) ); } join() { if ((this._val instanceof Left) || (this._val instanceof Right)) { return this._val; } return this; } chain(fn) { return fn(this._val); } toString() { const str = this._val.toString(); return `Right(${str})`; } } 

Returning to the railroad analogy .chain() switches the rails if we encounter an error. However, it is easier to show on the chart.


When an error occurs, the .chain () method allows you to switch to the left path. Note that the switches only work in one direction.

The code has become a bit cleaner:

 function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); const rowObj = fieldsEither.chain(zipRow(headerFields)); const rowObjWithDate = rowObj.chain(addDateStr); // Slowly getting better... but what do we return? } 

Do something with values


The processRow() function processRow() almost complete. But what happens when we return the value? In the end, we want to take different actions depending on what the situation is: Left or Right. Therefore, we will write a function that will take appropriate measures:

 function either(leftFunc, rightFunc, e) { return (e instanceof Left) ? leftFunc(e._val) : rightFunc(e._val); } 

I fooled and used the internal values ​​of the Left or Right objects. But pretend you didn't notice. Now we can complete our function:

 function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); const rowObj = fieldsEither.chain(zipRow(headerFields)); const rowObjWithDate = rowObj.chain(addDateStr); return either(showError, rowToMessage, rowObjWithDate); } 

And if we feel particularly clever, then we can again use the free syntax:

 function processRow(headerFields, row) { const rowObjWithDate = right(row) .map(splitFields) .chain(zipRow(headerFields)) .chain(addDateStr); return either(showError, rowToMessage, rowObjWithDate); } 

Both versions are pretty pretty. No designs try...catch. And there are no if statements in the top level function. If there is a problem with any particular string, we simply display an error message at the end. And note that processRow()we mention Left or Right only once at the very beginning when we call right(). The rest are only used methods .map()and .chain()for use the next function.

ap and lift


It looks good, but it remains to consider one last scenario. By sticking to our example, let's see how you can process all CSV data, and not just each row separately. We will need an auxiliary function (helper) or three:

 function splitCSVToRows(csvData) { // There should always be a header row... so if there's no // newline character, something is wrong. return (csvData.indexOf('\n') < 0) ? left('No header row found in CSV data') : right(csvData.split('\n')); } function processRows(headerFields, dataRows) { // Note this is Array map, not Either map. return dataRows.map(row => processRow(headerFields, row)); } function showMessages(messages) { return `<ul class="Messages">${messages.join('\n')}</ul>`; } 

So, we have a helper who breaks CSV into lines. And we return to the option with Either. Now you can use .map()some lodash functions to extract the header row from the data rows. But we find ourselves in an interesting situation ...

 function csvToMessages(csvData) { const csvRows = splitCSVToRows(csvData); const headerFields = csvRows.map(_.head).map(splitFields); const dataRows = csvRows.map(_.tail); // What's next? } 

We have header fields and data lines ready for display with processRows(). But headerFieldsalso dataRowswrapped in Either. We need some way to convert processRows()to a function that works with Either. To begin, we will curry processRows.

 function processRows(headerFields) { return function processRowsWithHeaderFields(dataRows) { // Note this is Array map, not Either map. return dataRows.map(row => processRow(headerFields, row)); }; } 

Now everything is ready for the experiment. We have headerFields, which is Either, wrapped around an array. What will happen if we take headerFieldsand call on it .map()with processRows()?

 function csvToMessages(csvData) { const csvRows = splitCSVToRows(csvData); const headerFields = csvRows.map(_.head).map(splitFields); const dataRows = csvRows.map(_.tail); // How will we pass headerFields and dataRows to // processRows() ? const funcInEither = headerFields.map(processRows); } 

With .map (), the external function is called here processRows(), but not the internal one. In other words, it processRows()returns a function. And since it is .map(), we still get back Either. Thus, the result is a function inside Either, which is called funcInEither. It takes an array of strings and returns an array of other strings. We need to somehow take this function and call it with the value inside dataRows. To do this, add another method to our classes Left and Right. Call it .ap()according to the standard .

As usual, the method does nothing on the Left track:

  // In Left (the sad path) ap() { return this; } 

And for the Right class, we expect another Either with a function:

  // In Right (the happy path) ap(otherEither) { const functionToRun = otherEither._val; return this.map(functionToRun); } 

Now we can complete our main function:

  function csvToMessages(csvData) { const csvRows = splitCSVToRows(csvData); const headerFields = csvRows.map(_.head).map(splitFields); const dataRows = csvRows.map(_.tail); const funcInEither = headerFields.map(processRows); const messagesArr = dataRows.ap(funcInEither); return either(showError, showMessages, messagesArr); } 

The essence of the method is .ap()immediately understood a little (the Fantasy Land specifications confusedly describe it, and in most other languages ​​the method is used the other way around). If it is easier to describe, then you say: “I have a function that usually takes two simple values. I want to turn it into a function that accepts two Either. ” If available, .ap()we can write a function that will do exactly that. Call it liftA2(), again according to the standard name. It takes a simple function that waits for two arguments, and “lifts” (lift) it to work with “applicatives”. (these are objects that contain both a method .ap()and a method .of()). Thus, liftA2 is an abbreviation for "lift lift, two parameters".

Thus, the function liftA2might look something like this:

 function liftA2(func) { return function runApplicativeFunc(a, b) { return b.ap(a.map(func)); }; } 

Our top-level function will use it as follows:

 function csvToMessages(csvData) { const csvRows = splitCSVToRows(csvData); const headerFields = csvRows.map(_.head).map(splitFields); const dataRows = csvRows.map(_.tail); const processRowsA = liftA2(processRows); const messagesArr = processRowsA(headerFields, dataRows); return either(showError, showMessages, messagesArr); } 

Code on CodePen .

True? It's all?


You ask, what is better than simple exceptions? Do not I think that this is too complicated a way to solve a simple problem? Let's first think about why we like exceptions. If there were no exceptions, I would have to write many if-statements everywhere. We will forever write code according to the principle “if the latter works, continue, otherwise handle the error”. And we have to handle these errors in the whole code. This makes it difficult to understand what is happening. Exceptions allow you to exit the program if something went wrong. Therefore, it is not necessary to write all these ifs. You can focus on a successful execution path.

But there is one snag. Exceptions hide too much. When you create an exception, you transfer the error handling problem to some other function. It is too easy to ignore the exception that will pop up to the top level. Either's nice side is that it allows you to jump out of the main program stream, as if with an exception. And it works honestly. You get either Right or Left. You cannot pretend that the Left option is not possible. In the end, you have to pull out the value of a call like either().

I know it sounds like some complexity. But take a look at the code we wrote (not the classes, but the functions that use them). There is not much exception handling code. It is almost none, except for the call either()at the end csvToMessages()andprocessRow(). That's the whole point. With Either, you have pure error handling that you can't accidentally forget. Without Either, you stamp through the code and add indents everywhere.

This does not mean that you can never use try...catch. Sometimes this is the right tool, and that's fine. But this is not the only tool. Either gives some advantages that are not try...catch. So give a chance to this monad. Even if it is difficult at first, I think you will like it. But please do not use the implementation from this article. Try one of the famous libraries such as Crocks , Sanctuary , Folktale or Monet . They are better served. And here, for simplicity, I missed something.

Additional resources


Source: https://habr.com/ru/post/457098/


All Articles