DEV Community

Alexey Tukalo
Alexey Tukalo

Posted on • Edited on

Functors and Monads in plain TypeScript

Many words are already said about Monads. I didn't really want to write yet another article explaining them. However, while I was working on documentation for my amonad library, I realized that I could not find any place in which I can refer for a quick introduction to the concept. Although, it appears to be too big to make it part of the documentation itself. So, here you are.

The monadic curse is that once someone learns what monads are and how to use them, they lose the ability to explain them to other people.

Douglas Crockford

I think it might be caused by a too high amount of mathematical details in full explanation. I will try my best to make this basic introduction more practical. Since I believe that I am still capable of explaining them to others, you might consider that I didn't completely get them.

So, I am sorry if there are some imprecisions in my reasoning, and I will be grateful to see any feedback or correction in the comments below.

Execution Order

The concepts we are going to discuss today initially came from purely functional programming languages. Naturally, they are not capable of expressing an execution order of computations. Hence, there is a formal way to achieve it.

Pure functions

Before jumping to the explanation of different possibilities to model execution order, let us take a glance at the feature which gave the name of the language family since it is going to be a great help further.

There are three criteria which have to be satisfied with the function to be pure:

  • It should not be capable of doing any side effects, or in other words, the external mutable state can not be modified.
  • The result of its work has to be represented by the return value.
  • The same combination of input arguments should always produce the same result.

Pure functions are highly deterministic. It is easy to reason about them since their application can be easily replaced with their bodies. This property is called referential transparency. It should be even possible to cache its computation's result and use a lookup table for the optimization of calls with the same input value. In some sense, such functions are similar to mathematical ones, like sin, cosine, sqrt, and so on. Does anybody remember how to use a logarithmic table?

Function composition

Due to outlined qualities, pure functions can be combined via a composition, like cos( sqrt(x) ). So, that sqrt is applied to x, and the result is passed to cos. It is actually possible to use any of them in such a way: foo2( foo1(arg) ).

If we take a closer look at the previously mentioned example, we will, eventually, find that an execution order can be preserved by function composition. The implication of the fact allows us to conclude that the code presented in two following listing is equivalent.

const result1 = foo1(arg1);
const result2 = foo2(result1);
Enter fullscreen mode Exit fullscreen mode
const result2 = foo2(
 foo1(arg1)
);
Enter fullscreen mode Exit fullscreen mode

It is also possible to extract function composition in a separate stage by defining a function which will compose two functions in a following way:

function compose <A, B, C>( f1: (value: A) => B, f2: (value: B) => C ): (value: A) => C {
    return value => f2( f1( value ) )
}
Enter fullscreen mode Exit fullscreen mode

It will give us a bit more coherent approach to rewriting the presented snippets.

const foo12 = compose(
 foo1,
 foo2
);
const result2 = foo12(arg1);
Enter fullscreen mode Exit fullscreen mode
const result2 = compose(
 foo1,
 foo2
)(arg1);
Enter fullscreen mode Exit fullscreen mode

Into functor

There is a drawback to such an implementation. It is hardly possible to define a function which is capable of combining an arbitrary number of functions in a type-safe manner. Indeed, it is possible to resolve the problem via a container encloses the value inside. It has to be able to manage a function application to its content.

There are only two things that are required in that case: container constructor and function, which can apply a transformation to the content. There is a common name for such an abstraction. It is called Functor.

interface Functor<A> {
    map<B>( transform: (value: A) => B ): Functor<B>;
}
Enter fullscreen mode Exit fullscreen mode

It allows as to express the piece of code following way:

const result2 = new Functor<Arg1>(arg1)
 .map(foo1)
 .map(foo2);
Enter fullscreen mode Exit fullscreen mode

The function responsible for application is traditionally called map or somehow similarly. In our case, for convenience reason, it was defined as a method. It takes a function that is intended for an application to the content of the Functor and applies it. The action produces a new value, which is again wrapped in a Functor of the same type, which provides us with a possibility to chain map calls.

It is also possible to define map as an external pure function.

function map<A, B>( functor: Functor<A>, transform: (value: A) => B ): Functor<B>
Enter fullscreen mode Exit fullscreen mode

In that case, it is a bit more clear why the preservation of order is concluded from a function composition.

const result2 = map(
    map(
        new Functor(arg1),
        foo1
    ),
    foo2
)
Enter fullscreen mode Exit fullscreen mode

Functor's anatomy

For a long time, I had the idea that limitations force creativity. So, a loss of ability to define an execution order brought people to Functor, which helps to preserve it via function composition. Besides that, it abstracts an actual application of a function to the data. That opens a possibility to internally adjust applied function in a wide range of ways.

Initially, we defined Functor as a simple container. However, it also can be considered as some new type, derived as a transformation of inclosed value, like Mapped types in TypeScript. Although, the container is just a a particular type of such a mapping. That's why the map method gets an additional responsibility of turning provided functions from a function capable of handling base type into a function able to work with value transformed by a Functor constructor. This way of thinking expands the range of abstractions that can be formalized by Functors.

The previously discussed example of Functor is, actually, the most basic one, and it is called Identity Functor. As you might already notice, the pattern is very similar to JavaScript's Array, since Array is one of the most common instances of Functor. Its map takes a function applicable to a type of single element of the Array and applies it to each value individually. In this way, it applies it to the entire Functor. Nowadays it is a well-known pattern for collections.

Another wide-spread example of such an approach is JavaScript Promise. It has a then method, which satisfies the signature of the map function and abstracts the fact that the data might be absent or only available later. It worth to mention that then's signature is a bit more sophisticated. Therefore Promise is a bit more than just another Functor.

Monad

Promise is utilized for modeling of asynchronous operations, which are mostly related to IO. Due to a particular case of its utilization, it frequently has to rely on previously retreated information. For example, there is an asynchronous call to a remote API, which gets information and utilizes it to subsequent requests, which is based on the prior result.

const result2 = getAsyncData1(arg1)
    .then( value => {
        const result1 = processValue(value);
        return getAsyncData2(result1);
    })
Enter fullscreen mode Exit fullscreen mode

The function passed to then() has a signature <A, B>(value: A) => Promise<B>. According to a signature defined for Functor, the type of result1 has to be Promise<Promise<Result1>>. It would be totally unbearable to write any relatively complex code which requires a lot of subsequent asynchronous operations. Moreover, it would not make any sense to have several nested Promises since all of them essentially represent the same. If any of the operations in the sequence are failed or delayed, a whole chain is going to be affected. Therefore it should be possible to flatten them in a single one.

The described situation is so wide-spread that then() was implemented in a special way to be capable of composing such embellished functions. It appears to be handy, that's why it was formalized under the name of Monad. Similarly to Functor, it has a constructor and a special function capable of applying value to the data hidden inside. Mathematically speaking, the function managing the application process is traditionally called bind. Nonetheless, in many programming languages, it is called simply flatMap. After all, the same can basically be achieved by a combination of map and flatten functions. I think the naming is quite apparent in that case.

interface Monad<A> {
    bind<B>( transform: (value: A) => Monad<B> ): Monad<B>;
}
Enter fullscreen mode Exit fullscreen mode

Due to the dynamic nature of JavaScript's type system, Promise.prototype.then() was implemented in a way that is capable of handling <A, B>(value: A) => B as well as <A, B>(value: A) => Promise<B> functions — thus satisfying requirements for Monad as well as for Functor.

Conclusion

In the article, we discussed the possibility of expressing an execution order via function compositions and how it naturally leads us to the concept of Functors and later Monads. Monads are superior to Functors, because they are capable of modelling Functor by raising the output type of A -> B function to Monad<B> type with a Monad constructor if needed.

Array and Promise were described as the most well-known examples of their kinds, while there are a lot of other useful Monads and ways to apply them in real-life applications. Monad is a very powerful abstraction that opens a lot more exotic possibilities that lie beyond the scope of the introduction article.

There is a TypeScript implementation of Result and Maybe Monads in amonad library. The documentation has a pretty good introduction to them and their usage. It will help you to expand your knowledge of the topic and learn more about monadic error handling, particularly.

Top comments (0)