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.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
.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
<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>
function splitFields(row) { return row.split('","'); }
_.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); }
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}; }
const rowToMessage = _.template(`<li class="Message Message--<%= viewed %>"> <a href="<%= href %>" class="Message-link"><%= content %></a> <time datetime="<%= datestamp %>"><%= datestr %></time> <li>`);
const showError = _.template(`<li class="Error"><%= message %></li>`);
function processRow(headerFieldNames, row) { try { fields = splitFields(row); rowObj = zipRow(headerFieldNames, fields); rowObjWithDate = addDateStr(rowObj); return rowToMessage(rowObj); } catch(e) { return showError(e); } }
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); }
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.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?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. 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); }
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}); } };
.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); }
.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})`; } }
.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);
/** * 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})`; } }
const leftHello = new Left('Hello world'); const rightHello = new Right('Hello world'); leftHello.runFunctionOnlyOnHappyPath(trace); // does nothing rightHello.runFunctionOnlyOnHappyPath(trace); // ⦘ Hello world // ← "Hello world"
.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})`; } }
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)
new Right()
, or on a bad one by calling new Left()
.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
..map()
to move along the path..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); };
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); }
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}); }
right()
, and then broadcast splitFields
to split it: function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); // … }
zipRow()
: function processRow(headerFields, row) { const fieldsEither = right(row).map(splitFields); const rowObj = fieldsEither.map(zipRow /* wait. this isn't right */); // ... }
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)); }; }
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 ... }
.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})`; } }
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? }
.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})`; } }
.chain()
switches the rails if we encounter an error. However, it is easier to show on the chart. 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? }
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); }
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); }
function processRow(headerFields, row) { const rowObjWithDate = right(row) .map(splitFields) .chain(zipRow(headerFields)) .chain(addDateStr); return either(showError, rowToMessage, rowObjWithDate); }
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. 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>`; }
.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? }
processRows()
. But headerFields
also dataRows
wrapped 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)); }; }
headerFields
, which is Either, wrapped around an array. What will happen if we take headerFields
and 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); }
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 . // In Left (the sad path) ap() { return this; }
// In Right (the happy path) ap(otherEither) { const functionToRun = otherEither._val; return this.map(functionToRun); }
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); }
.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".liftA2
might look something like this: function liftA2(func) { return function runApplicativeFunc(a, b) { return b.ap(a.map(func)); }; }
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); }
either()
.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.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
- « », ( .)
- Fantasy Land
- « JavaScript: Either »,
- « JavaScript: »,
Source: https://habr.com/ru/post/457098/
All Articles