Functional Programming in JavaScript, Part 4: The Art of Chaining Different Monads
This is part 4 in a series on Functional Programming in JavaScript
Previously on "Functional Programming in JavaScript"
Last time, I introduced the concepts of Functor and Monad.
The concept of Functor comes from functional programming. It is a way to have an object apply a function to a value for us. So instead of passing the value to a list of functions, we pass the functions to the value. This way we don't have to check the value type, or to check it it's an error.
To achieve this, a Functor takes a value and returns an object wrapping the value and adding functionality to it. The resulting object is called a functor with a lowercase f - just like the Number constructor would return a number.
And of course, there are many Functors, each adding different functionality: Type checking with the TypeBox Functor, null checking with the Maybe Functor; or code branching and error handling with the Either Functor.
And then we encountered an issue: if we map a function returning a functor, we end up with a functor inside a functor. In that case, we cannot access the value easily anymore. So we found a way to remove the extra functor by flattening our functors. In the process, we discovered the chain method, as well as the famous Monad.
And I explained how we can chain a maybe with an either:
codepen.io might track you and we would rather have your consent before loading this.
And it worked. But truth be told, it's not the proper way to do this.
Flattening Monads, The Wrong Way
Let's look again at the end of the example:
const parseMail = user =>
Maybe(user)
.map(get("mail"))
.chain(mail => validateMail(mail).catch(error => error.message))
.getOrElse("no mail");
In detail:
// Here we have a user or nothing.
// If we have nothing, all the following lines will be ignored until `getOrElse`,
// that gets the value or a default one.
Maybe(user)
// We access the user mail, and now we have the mail or nothing.
// Again if we have nothing we will skip until `getOrElse`.
.map(get("mail"))
// And now we chain `validateMail` that takes a string and returns an *either*:
// - `Right(mail)` if the string was an email
// - `Left(new Error('Invalid mail'))` otherwise.
.chain(mail => validateMail(mail).catch(error => error.message));
Up to there it's cool, we know whether we have a mail or not.
But then we use .catch()
, which turns the eventual left into a right. After this, no matter if the mail is valid or not, we have a right holding a string either way. And even if we did not catch, then once the chain resolves and the either gets flattened with the maybe, we would get Nothing()
or Maybe(mail)
or Maybe(new Error('Invalid mail'))
.
In other words, the result of the chain
call is the value from the either, not an either. We have no way of knowing if it was a result or an error. When we flatten a maybe with an either, we lose the context of the either.
Maybe(Right("bar@example.com")).flatten(); // Maybe('bar@example.com')
Maybe(Left(new Error("Invalid mail"))).flatten(); // Maybe(new Error('Invalid mail'))
The current chain()
implementation, when given monads of two different types, returns a monad of the first type. And that is the main issue when we flatten different kind of Monads together.
Update 11/2019: Now there is something really important I totally forgot to tell you in the first version of this article. The previous implementation of flatten is wrong: It tries to do too much. It extracts the value of the nested monad and puts it in a new monad of the parent type. This is what allows to chain different monads together in simple cases. But as I told you, we should never do that.
For example the Right implementation should become:
const Right = value => ({
map: fn => Right(fn(value)),
- flatten: () => Right(value.value),
+ flatten: () => value,
- chain(fn) {
- return Right(this.map(fn).flatten());
- },
+ chain(fn) {
+ return this.map(fn).flatten();
+ },
catch: fn => Right(value),
value,
});
That's right, flatten
simply returns the nested value. And chain
is just a map
followed by a flatten
.
No need to know how to extract the value of the nested Monad, we simply need to return it.
With this change, the previous code would not work anymore, since the Monad type would change midway. Which is fine since we should not have tried to flatten two monads of different type anyway.
So how do we proceed ?
How To Use Different Kinds of Monads Together?
So we should not flatten two different monads (like a maybe and an either) together. But we sure can flatten two monads of the same kind. Hence, if we have a Maybe(Either(value))
, and if we can somehow turn it into a Maybe(Maybe(value))
, then we can flatten the two Maybes. We need a function called eitherToMaybe
:
Maybe({
name: "foo",
mail: "bar@example.com",
})
.map(v => v.mail)
.map(validateMail)
.map(eitherToMaybe)
.flatten();
or shorter :
Maybe({
name: "foo",
mail: "bar@example.com",
})
.map(v => v.mail)
.map(validateMail)
.chain(eitherToMaybe);
// to chain is the shorthand for map + flatten
Ok, but how does eitherToMaybe
work?
Well, what we want is a function that takes an either and returns a maybe. Since we know what we convert, it is easy to convert the context, too.
If either is a Right(value)
, then we return a Maybe(value)
eitherToMaybe(Right("bar@example.com")); // Maybe('bar@example.com')
If either is a Left(error)
, then we return Nothing()
. We do not keep the error, because Maybe can only tell us if we a have a value or not.
eitherToMaybe(Left("Invalid mail")); // Nothing()
Here is the implementation:
const eitherToMaybe = either => Maybe(either.catch(e => null).flatten());
And now:
Maybe({
name: "foo",
mail: "bar@example.com",
})
.map(v => v.mail)
.map(validateMail)
.chain(eitherToMaybe);
// returns
Maybe("bar@example.com");
Maybe({
name: "foo",
mail: "bar",
})
.map(v => v.mail)
.map(validateMail)
.chain(eitherToMaybe);
// returns
Maybe.Nothing();
Let's now see how we would do the opposite conversion, converting a maybe to an either.
If the maybe holds a value, then we convert it to a right.
maybeToEither(Maybe("some value")); // Right('some value')
But if the maybe is Nothing, then we convert it to a left with a message indicating that there is no value.
maybeToEither(Maybe.Nothing()); // Left('no value')
And so here is the maybeToEither
implementation:
const maybeToEither = maybe =>
maybe.isNothing() ? Left("no value") : Right(maybe.flatten());
And now the context gets preserved: We have no value.
This technique is called a Natural Transformation. A Natural Transformation is a Transformation that changes the container without changing the value. Think of it as a type conversion but for Monad
.
With natural transformations, the order doesn't matter. You can change the value, then naturally transform the container, it will be the same as if you first convert, then transform the value.
Either(5)
.chain(eitherToMaybe)
.map(v => v * 2); // Maybe(10)
Either(5)
.map(v => v * 2)
.chain(eitherToMaybe); // Maybe(10)
Here is the corrected example from the previous article:
Conclusion
Every Monad should offer functions to convert it toward other types of Monad. If not, it's rather simple to add your own.
So this is how you chain different monads together. You use converters function to transform the nested monad to be the same type as its parent. And then you can safely flatten the two.