神刀安全网

Practical Intro to Monads in JavaScript: Either

My simple and practical Intro to Monads in JS , where I covered basics of Identity and Maybe monads, seemed to be helpful for a lot of folks, so I’ve decide to continue the topic. Now it’s time for Either – a tool for fast-failing, synchronous computation chains. A tool that may increase readability and quality of code while reducing error proneness. The best part is that we can still ignore the category theory!

An ounce of practice is generally worth more than a ton of theory.

Ernst F. Schumacher

All examples are based on monet.jsa tool bag that assists Functional Programming by providing a rich set of Monads and other useful functions. I’ll use arrow functions introduced in ES6. They are much more readable than regular functions when used as single operation “callbacks” . Also in most examples TypeScript type definitions will be added to enhance overall readability.

Do you remember the Maybe monad ? The one that could have been Some(value) or Nothing ? This is great when we have a function that can fail in one way – i.e .pop() from an array. If there’s no element return Nothing . However, the Maybe monad loses expressivity when we have many things that can go wrong. E.g. getting some value from the encoded JSON – a corrupted JSON, an empty JSON or an empty field that should hold the value. We need a way to communicate multiple failures, or a success, enter Either . It can have a value on either Right(value) or Left(error) side. For example Some(value) on the Right or an Error corresponding with Nothing on the Left (never both at the same time).

Behold the Either monad!

Either fail or success

Let’s take a function from the previous article:

function getCurrentUser(): Maybe<User> { /* some implementation */ }; 

If we cannot get the current user, there must be a reason. So we should get an information why this exceptional situation have happened – an Error object. Look at our new declaration:

function getCurrentUser(): Either<Error, User>; 

It will return either Right(User) or Left(Error) . Of course Either can be parameterized with any pair of values, but it’s often used as a container for a value (right) or an exception info (left). As monet.js docs state the most common is right biased either (success on the right) which means that .map() and .flatMap() will operate on the right side of the either .

Either in practice

Generally, the use of exceptions for ordinary control flow is considered an anti-pattern . We can choose a real life computation, which fails or returns a value, and decorate it so it returns an Either . For example, it can be an enhanced JSON.parse() which returns Either<Error, any> . It means that if .parse() fails we will get Left(Error) or if it succeeds we’ll get Right<any> :

function parseJSON(json: string): Either<Error, any>; 

The any type here is because we actually don’t know what type of data is serialized as a JSON string. But we can expect it to be a some type T , so our function can look like this:

function parseJSON<T>(json: string): Either<Error, T>; 

So now we should implement what we have declared:

function parseJSON<T>(json: string): Either<Error, T> {     try {         return Right(<T>JSON.parse(json));     } catch (e: Error) {         return Left(e);     } } 

Great. So what can we do with that?

I. Parse that stringy JSON

We can for example parse some JSON serialized information, take some value from it and write it into a HTML element. Or log error – why we failed. We can now for e.g. try to parse our JSON and write "title" into <h1/> . So step by step:

Starting point

Let’s say we have a JSON data that has a form of:

{     "title": "Some string title",     "desc": "Some description",     … } 

and it’s already stored as a string in const json: string; . So we assume that our parsed JSON should fit this declaration:

interface Config {     title: string;     desc: string; } 

Get data

const eitherData = parseJSON<Config>(json); 

The eitherData value here will be of type Either<Error, Config> . If it’s Right(Config) we are pretty sure that it was valid JSON, but what if it’s empty?

Catch empty data

Let’s make sure that we proceed only if it’s concrete:

const eitherData = parseJSON<Config>(json)     .flatMap(data => data ?          Right(data) :          Left(new Error('Parsed data is empty.'))); 

Why .flatMap() ? Remember how we have searched for a name of a Maybe user ? This method is useful for converting value of initial Either (and it will work only if it’s Right ) to another EitherLeft or Right . Analogically .map() will do anything with the value of Either only if the Either is Right . So if we have many methods that return Either<E,A> we want to be able to call them sequentially. With Either , the chain between these methods is linked only on the Right side. If any method in the chain returns a Left , the computation fails at that point returning that Left value.

The eitherData is still Either<Error, Config> , but we are now sure that it’s not Right(null) .

Extract valuable information

Next we need to get a "title" field. And handle the lack of title error:

const eitherTitle = parseJSON<Config>(json)     .flatMap(data => data ?          Right(data) :         Left(new Error('Parsed data is empty.')))     .flatMap(data => data.title ?          Right(data.title) :         Left(new Error('Parsed data has no "title" field.'))); 

Note that the current output is eitherTitle of type Either<Error, string> . So now we have Either a Right("Title text") or Left(Error("Failure information")) .

Final Error handling

I’ve used Error objects on the Left which gives almost no information for the type system. It would be much better to introduce the enumeration to carry specific information about what has gone wrong. For example:

enum Err { BadJson, EmptyJson, NoTitle, NoTag } 

…and then we can:

const eitherTitle = parseJSON<Config>(json)     .flatMap(data => data ? Right(data) : Left(Err.EmptyJson))     .flatMap(data => data.title ? Right(data.title) : Left(Err.NoTitle)); 

…and fix parseJson so it also uses our custom Err enum value instead of Error object:

function parseJSON<T>(json: string): Either<Err, T> 

Enhance display value

Now, when we are sure we have a “title” string or an exception, we can produce the final display value. Let’s map our title to a version prefixed with 'Title: ' and wrapped with " :

const eitherDisplayTitle = parseJSON<Config>(json)     .flatMap(data => data ? Right(data) : Left(Err.EmptyJson))     .flatMap(data => data.title ? Right(data.title) : Left(Err.NoTitle))     .map(title => `Title: "${ title }"`); 

Where `Title: "${ title }"` is ES6 interpolation .

Note that if parseJSON fails, the whole computation stops. We go straight to end of chain and get Left(Error("Some info) . Our computation chain will also stop at any further .flatMap() that returned Left and go to the end. This superpower is called fail-fast error handling .

OK. The first part is done. We have the display information. Now we have to render it.

Cata… clysm once more

Rememberwtf is catamorphism? So here we’ll use it once more. As a final point of our computations so we can handle either final value or exception. Of course we can do that without .cata() :

if (eitherDisplayTitle.isLeft()) {     console.error(Err[eitherDisplayTitle.left()]); } else if (eitherDisplayTitle.isRight()) {     document.write(eitherDisplayTitle.right()); } 

Pretty verbose. And imperative. Try a more awesome version:

eitherDisplayTitle.cata(err => {     console.error(Err[err]); }, displayTitle => {     document.write(displayTitle); }); 

Functional freaks will burn at the stake. So here is more kosher version – move side effects as far away as possible:

type Renderer = () => void;  const renderTitle: Renderer = eitherDisplayTitle.cata(err => () => {     console.error(Err[err]); }, displayTitle => () => {     document.write(displayTitle); });  renderTitle(); 

The big picture – part I

So now we have a complete form raw JSON to simple view chain:

const renderTitle = parseJSON<Config>(json)     .flatMap(data => data ? Right(data) : Left(Err.EmptyJson))     .flatMap(data => data.title ? Right(data.title) : Left(Err.NoTitle))     .map(title => `Title: "${ title }"`)     .cata(err => () => {         console.error(Err[err]);     }, displayTitle => () => {         document.write(displayTitle);     });  // And then we can: renderTitle();   

But that is only a half of what we wanted to achieve.

II. Grab the node

How can we fill element with text?

elem.innerHTML = "some text"; 

But where did the element came from? We have to grab it. Some possible ways are .querySelector(selector) , .querySelectorAll(selector) or .getElementById(id) . I’ll use first one to grab a heading and fill it with text:

document.querySelector('h1').innerHTML = "some text"; 

So what if there is no <h1/> on page? It will throw. So maybe we can check:

const elem = document.querySelector('h1'); if (elem) {     elem.innerHTML = "some text"; } 

Or maybe we can create own element getter that will return a Maybe<HTMLElement> ?

function getElem(selector: string): Maybe<HTMLElement> {     return Maybe.fromNull(document.querySelector(selector)); } 

The big picture – part II

Now we have working example:

const renderTitle = parseJSON<Config>(json)     .flatMap(data => data ? Right(data) : Left(Err.EmptyJson))     .flatMap(data => data.title ? Right(data.title) : Left(Err.NoTitle))     .map(title => `Title: "${ title }"`)     .cata(err => () => {         console.error(Err[err]);     }, displayTitle => () => {         getElem('h1').cata(() => {}, heading => {             heading.innerHTML = displayTitle;         });     });  // And then we can: renderTitle();   

But it ain’t no good. We have side effect inside catamorphism. Also flow can stuck in a dead end – if there is no <h1/> then nothing will happen. No action and no information – debugging nightmare. So…

III. Wire it up

First of all, to upgrade developers experience we can do one of the two things:

– find a good fallback ( .orSome() ) for our element getter

– provide information about lack of elements corresponding to provided selector

I do not see a good fallback – I’ll take more informative approach. Let’s make getElem return either Right(element) or Left(Err.NoTag) instead of Maybe<HTMLElement> .

function getElem(selector: string): Either<Err, HTMLElement> {     return Maybe.fromNull(document.querySelector(selector))         .toEither(Err.NoTag); } 

Now we can:

type Renderer = () => void;  const renderTitle: Renderer = eitherDisplayTitle.cata(err => () => {     console.error(Err[err]); }, displayTitle => getElem('h1')     .cata(err => () => {         console.error(Err[err]);     }, heading => () => {         heading.innerHTML = displayTitle;    }));  renderTitle(); 

Working. But what a mess!

The right path

Let’s create something that will take our ingredients and give us a dish. It sounds like a function:

function fillElem(content: string, elem: HTMLElement): void {     elem.innerHTML = content; } 

I think it is not enough. We need to pass these ingredients one-by-one and get a factory rather than ready meal. To make it let’s add some curry .

Currying

What? Why? Currying is an idea that a function can collect its arguments one-by-one (not all at once) and will do the final computation (and return the final value) when all arguments are in. In the JavaScript world it can be achieved with a function taking the first argument and returning a function taking the second argument which returns the third one… etc. etc. The function that is supposed to take the last value does the computation and returns a value. Our needed value here is no-args-no-return function that will do an I/O operation – will display text. So here it goes:

function fillElem(content: string) {     // Now we have "content"     // We have to collect "elem"      return function fillElemWith(elem: HTMLElement) {         // Now we have "content" and "elem"         // We have to provide I/O function          return function render(): void {             elem.innerHTML = content;         };     }; } 

Of course thanks to ES6 and TypeScript we can simplify it with => syntax sugar:

const fillElem = (content: string) => (elem: HTMLElement) => (): void => {     elem.innerHTML = content; } 

Now we are able to collect the ingredients step by step in our computation chain, and postpone any I/O operations as long as possible.

Currying in practice

At some point in the article we had such situation:

const eitherDisplayTitle = parseJSON<Config>(json)     .flatMap(data => data ? Right(data) : Left(Err.EmptyJson))     .flatMap(data => data.title ? Right(data.title) : Left(Err.NoTitle))     .map(title => `Title: "${ title }"`); 

Now we can go one step further and collect wrapped title into our curried function:

const eitherFillElemWith = eitherDisplayTitle     .map(title => fillElem(title)); 

As title => fillElem(title) actually does the same as the fillElem alone, we can:

const eitherFillElemWith = eitherDisplayTitle.map(fillElem); 

What do we need it for? Now we can grab element and pass it to our curried function. Let’s say we already have const title: string; which is proper "title" extracted successfully from JSON. Now we can pass another ingredient:

// We have "title" and passed it to curried function: const fillElemWithTitle = fillElem(title);  // We can now: const eitherElem = getElem('h1'); const eitherRender = eitherElem.map(elem => {     const render = fillElemWithTitle(elem);     return render; });  // Or in more compact way: const eitherRender = getElem('h1').map(elem => fillElemWithTitle(elem)); 

Putting few last steps together:

const eitherRender: Either<Err, () => void> =      eitherDisplayTitle         .map(fillElem)         .flatMap(fillElemWithTitle => {             return getElem('h1').map(fillElemWithTitle);         }); 

Either render title or log error

So we finally have either render() function or an Error to log:

eitherRender.cata(err => () => {     console.log('"renderTitle" failed');     console.error(Err[err]); }, render => () => {     console.log('"renderTitle" success');     render();  })(); 

The big picture – part III (final)

With prepared helpers:

function parseJSON<T>(json: string): Either<Error, T> {     try {         return Right(<T>JSON.parse(json));     } catch (e: Error) {         return Left(e);     } }  function fillElem(content: string) {     return (elem: HTMLElement) => (): void => {         elem.innerHTML = content;     }; } 

Here is a final, working example:

const renderTitle = parseJSON<Config>(json)     .flatMap(data => data ? Right(data) : Left(Err.EmptyJson))     .flatMap(data => data.title ? Right(data.title) : Left(Err.NoTitle))     .map(title => `Title: "${ title }"`)     .map(fillElem)     .flatMap(fillElemWithTitle => getElem('h1').map(fillElemWithTitle));     .cata(err => () => {         console.log('"renderTitle" failed');         console.error(Err[err]);         // Handle 'err' somehow…     }, render => () => {         console.log('"renderTitle" success');         render();     });  renderTitle(); 

Visual explanation:

Practical Intro to Monads in JavaScript: Either

And a plunker playground covering this article.

What else?

As you see, using monads (and other beings from the monadic world) requires a little intellectual exercise. But not that much. And at the end of a day you increase readability and quality of code. Application becomes less error prone. And Haskell freaks may even notice you around. This was quite simple example but that’s ok – monads are good tools for simple problems. They are even better tools for large, complex problems. I’d say they are Just(awesome).

Consider other use cases:

– unpacking Response object – res.ok ? Left(res) : Right(res)
– nested relationships can be either Left("id") or Right(Resource)

– complicated computations where you need fail-fast error handling

– etc…

I have covered basics of Identity , Maybe and Either . In next article you can expect meat and potatoes of an error accumulation with the Validation (which is not exactly a monad, but shares a lot with monads).

Further reading:

转载本站任何文章请注明:转载至神刀安全网,谢谢神刀安全网 » Practical Intro to Monads in JavaScript: Either

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
分享按钮