DEV Community

Benoit Ruiz
Benoit Ruiz

Posted on

Equivalent of Scala's for-comprehension using fp-ts

Table of contents


If you are only interested in the final result with some takeaways, feel free to jump to the Summary section.

Introduction

Hello and welcome!

If you come from the Scala world, and you are wondering how you can replicate a for comprehension expression in TypeScript, then you may be interested in this article.

Today, we are going to see how we can write such an expression using TypeScript and the fp-ts ecosystem.

The objective of this article is to transform the following Scala blocks of code:

Case 1

def foo(): Option[String] = ???
def bar(a: String): Option[Int] = ???
def baz(b: Int): Option[Int] = ???

for {
  a <- foo()
  b <- bar(a)
  c <- baz(b) if b >= 42
} yield (b + c)

// Option[Int]
Enter fullscreen mode Exit fullscreen mode

Case 2

// Here, we are using some arbitrary `IO` data type.
// This could come from the cats-effect library for example.

def foo(): IO[String] = ???
def bar(): IO[Unit] = ???
def baz(a: String): IO[Int] = ???
def qux(b: Int): IO[Unit] = ???

for {
  a <- foo()
  _ <- bar()
  b <- baz(a)
  _ <- qux(b)
} yield (b + 1)

// IO[Int]
Enter fullscreen mode Exit fullscreen mode

Both have some common behaviors: bindings to intermediate references such as a and b, and yielding a final value. In addition, the first one has a filtering step, and the second one has some discarded results (i.e. running side effects that do not return any value).

Depending on the version of fp-ts you are using, you might have to follow either the fp-ts or the fp-ts-contrib way. Please refer to the Prerequisites section for more information.

In both cases, we are going to use the do notation.

Prerequisites

For both ways, here are the function declarations we are going to use:

Case 1

import * as O from 'fp-ts/Option'

declare function foo(): O.Option<string>
declare function bar(a: string): O.Option<number>
declare function baz(b: number): O.Option<number>
Enter fullscreen mode Exit fullscreen mode

Case 2

import * as I from 'fp-ts/IO'

declare function foo(): I.IO<string>
declare function bar(): I.IO<void>
declare function baz(a: string): I.IO<number>
declare function qux(b: number): I.IO<void>
Enter fullscreen mode Exit fullscreen mode

Using fp-ts

Case 1

Let us build the for comprehension expression step by step.

First, we have to start the do notation. There are 2 ways of doing this:

  • Using Do and bind:

    import * as O from 'fp-ts/Option'
    import { pipe } from 'fp-ts/function'
    
    pipe(
      O.Do,
      O.bind('a', () => foo())
    )
    
  • Calling foo, then using bindTo:

    import * as O from 'fp-ts/Option'
    import { pipe } from 'fp-ts/function'
    
    pipe(
      foo(),
      O.bindTo('a')
    )
    

There is no preferred way, although I personally like to make it explicit with the Do step.

At this point, the type of this expression is Option<{ a: string }>.


Tip: on VS Code, you can inspect the type inferred by TypeScript with the help of an identity function and ctrl + mouseover the parameter (cmd + mouseover on Mac):

Use of ctrl+mouseover on VS Code, over the x parameter of the identity function x => x, inserted as the last argument of the pipe(...) function

I use this identity function quite a lot to make sure I have made the correct value transformations, step by step.


The next step is b <- bar(a). For this, we are going to use bind, while accessing the previous value bound to the a reference:

pipe(
  ...,
+  O.bind('b', ({ a }) => bar(a))
)
Enter fullscreen mode Exit fullscreen mode

In this step, the second argument of bind is a function whose parameter is a { a: string }, i.e. whatever is inside the Option at this point. This allows us to read the previous value to call the bar function, and get a new value bound to the b reference.

Following this step, the type has become Option<{ a: string, b: number }>.

The next line, c <- baz(b) if b >= 42, is the most complicated one. It is composed of 2 steps: a filter, and some binding of a value.

At the time of writing these lines (fp-ts v2.11.8), there is no function available that combines both these steps, so we have to write them explicitly:

pipe(
  ...,
+  O.filter(({ b }) => b >= 42),
+  O.bind('c', ({ b }) => baz(b))
)
Enter fullscreen mode Exit fullscreen mode

We could create an intermediate function, bindFilter, that would combine these 2 steps:

import { flow } from 'fp-ts/function'

const bindFilter: <N extends string, A, B>(
  name: Exclude<N, keyof A>,
  f: (a: A) => O.Option<B>,
  predicate: (a: A) => boolean
) => (ma: O.Option<A>) => O.Option<{ readonly [K in N | keyof A]: K extends keyof A ? A[K] : B }> =
  (name, f, predicate) => flow(O.filter(predicate), O.bind(name, f))

pipe(
  O.Do,
  ...,
  bindFilter('c', ({ b }) => baz(b), ({ b }) => b >= 42)
)
Enter fullscreen mode Exit fullscreen mode

However, we would have to write a different version of bindFilter for each instance of Monad used in our code, e.g. for Either, IO, Task... This could be quite tedious just to abstract the filter + bind calls.

Alternatively, we could use flow to regroup these 2 steps into a single one:

+ import { flow } from 'fp-ts/function'

pipe(
  ...,
+  flow(O.filter(({ b }) => b >= 42), O.bind('c', ({ b }) => baz(b)))
)
Enter fullscreen mode Exit fullscreen mode

Following this new addition, the type of our expression has changed to Option<{ a: string, b: number, c: number }>.

Finally, we have the yield (b + c) part. This one can be achieved using map:

pipe(
  ...,
+  O.map(({ b, c }) => b + c)
)
Enter fullscreen mode Exit fullscreen mode

The final type has become Option<number>, which is what we were expecting to get.

Final result:

import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/function'

pipe(
  O.Do,
  O.bind('a', () => foo()),
  O.bind('b', ({ a }) => bar(a)),
  O.filter(({ b }) => b >= 42),
  O.bind('c', ({ b }) => baz(b)),
  O.map(({ b, c }) => b + c)
)
Enter fullscreen mode Exit fullscreen mode

Case 2

A lot of what we are going to use has already been seen in the previous section, for the first case. The only difference here is that we have some steps that do not return any value.

First, let us start the do notation:

import * as I from 'fp-ts/IO'
import { pipe } from 'fp-ts/function'

pipe(
  I.Do
)
Enter fullscreen mode Exit fullscreen mode

Then, we can add the a <- foo() step:

pipe(
  I.Do,
+  I.bind('a', () => foo())
)
Enter fullscreen mode Exit fullscreen mode

So far, the type of our expression is IO<{ a: string }>.

Following this, we have our first case where we do not care about the returned value: _ <- bar(). Since we ignore the result of calling bar, we are going to use chainFirst:

pipe(
  ...,
+  I.chainFirst(() => bar())
)
Enter fullscreen mode Exit fullscreen mode

Here, we still have the same type for this expression (i.e. IO<{ a: string }>) since we ignored whatever bar() was returning.

Now comes the b <- baz(a) step. As seen in the previous section, we are going to use bind, while accessing the previous value bound to the a reference:

pipe(
  ...,
+  I.bind('b', ({ a }) => baz(a))
)
Enter fullscreen mode Exit fullscreen mode

Following this step, the type has become IO<{ a: string, b: number }>.

The next step is another effect where we ignore the value it returns. However, now we need to access a previously bound value:

pipe(
  ...,
+  I.chainFirst(({ b }) => qux(b))
)
Enter fullscreen mode Exit fullscreen mode

The type of the expression is still IO<{ a: string, b: number }>, since we did not bind any new value.

Finally, we have the yield (b + 1) part, that we can handle using map:

pipe(
  ...,
+  I.map(({ b }) => b + 1)
)
Enter fullscreen mode Exit fullscreen mode

The final type has become IO<number>, which is what we were expecting to get.

Final result:

import * as I from 'fp-ts/IO'
import { pipe } from 'fp-ts/function'

pipe(
  I.Do,
  I.bind('a', () => foo()),
  I.chainFirst(() => bar()),
  I.bind('b', ({ a }) => baz(a)),
  I.chainFirst(({ b }) => qux(b)),
  I.map(({ b }) => b + 1)
)
Enter fullscreen mode Exit fullscreen mode

Using fp-ts-contrib

Case 1

Let us build the for comprehension expression step by step.

First, we have to start the do notation:

import { Do } from 'fp-ts-contrib/Do'
import * as O from 'fp-ts/Option'

Do(O.Monad) // or, Do(O.option) for fp-ts prior v2.11
Enter fullscreen mode Exit fullscreen mode

Then comes the first step of this expression, a <- foo(). For this, we can use the bind method:

Do(O.Monad)
+  .bind('a', foo())
Enter fullscreen mode Exit fullscreen mode

Next, we have b <- bar(a), which requires us to use the bindL method to access the a reference:

Do(O.Monad)
  ...
+  .bindL('b', ({ a }) => bar(a))
Enter fullscreen mode Exit fullscreen mode

Following this, we have the filtering part, c <- baz(b) if b >= 42. This is going to feel a bit "hacky" since, at the time of writing these lines (fp-ts-contrib v0.1.26), there is no filter method available in the Do builder.

To add this filter, we have to end the do expression to get a Option<{ a: string, b: number }>, then apply the filter on it, then chain the filtered Option with a new do expression to continue the computation:

+import { pipe } from 'fp-ts/function'
+
+pipe(
  Do(O.Monad)
    .bind('a', foo())
    .bindL('b', ({ a }) => bar(a))
+    .done(),
+  O.filter(({ b }) => b >= 42),
+  O.chain(({ a, b }) => Do(O.Monad)
+    .bind('c', baz(b))
+    .return(({ c }) => b + c)
+  )
+)
Enter fullscreen mode Exit fullscreen mode

In the fp-ts world, chain is the same thing as flatMap.

Here are some explanations to understand what is happening there:

  • We end the first do notation with .done(), which yields Option<{ a: string, b: number }>.
  • We filter this value, using O.filter and a predicate.
  • At this point, we still have a Option<{ a: string, b: number }>, but we need to go back in a Do builder. For that, the trick is to chain the option we have with a new option that we will get with a new Do builder.
  • By chaining, we have access to the previous references a and b. Be careful though, as they are not available in the new Do builder.
  • We use the .return(...) method to yield an Option<number>, which is the type of value we are expecting.

Maybe someday we will have a DoFilterable builder that will feel more natural, as suggested in an open issue on the fp-ts-contrib repository.

Final result:

import { Do } from 'fp-ts-contrib/Do'
import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/function'

pipe(
  Do(O.Monad)
    .bind('a', foo())
    .bindL('b', ({ a }) => bar(a))
    .done(),
  O.filter(({ b }) => b >= 42),
  O.chain(({ a, b }) => Do(O.Monad)
    .bind('c', baz(b))
    .return(({ c }) => b + c)
  )
)
Enter fullscreen mode Exit fullscreen mode

Case 2

As usual, let us start the do notation:

import { Do } from 'fp-ts-contrib/Do'
import * as I from 'fp-ts/IO'

Do(I.Monad) // or, Do(I.io) for fp-ts prior v2.11
Enter fullscreen mode Exit fullscreen mode

The next step, a <- foo(), is pretty straightforward:

Do(I.Monad)
+  .bind('a', foo())
Enter fullscreen mode Exit fullscreen mode

Following this, we have the first case where we ignore the returned value. For this, we can use the do method:

Do(I.Monad)
  ...
+  .do(bar())
Enter fullscreen mode Exit fullscreen mode

Then, we can use bindL to implement the b <- baz(a) part:

Do(I.Monad)
  ...
+  .bindL('b', ({ a }) => baz(a))
Enter fullscreen mode Exit fullscreen mode

After that, we have a second case where the returned value is ignored. However this time, we need a previous value: _ <- qux(b). For that, we can use the doL method:

Do(I.Monad)
  ...
+  .doL(({ b }) => qux(b))
Enter fullscreen mode Exit fullscreen mode

The last step, yield (b + 1), can be achieved using the return method:

Do(I.Monad)
  ...
+  .return(({ b }) => b + 1)
Enter fullscreen mode Exit fullscreen mode

Final result:

import { Do } from 'fp-ts-contrib/Do'
import * as I from 'fp-ts/IO'

Do(I.Monad)
  .bind('a', foo())
  .do(bar())
  .bindL('b', ({ a }) => baz(a))
  .doL(({ b }) => qux(b))
  .return(({ b }) => b + 1)
Enter fullscreen mode Exit fullscreen mode

Summary

Sadly, there is no syntactic sugar available for monad composition in TypeScript. As fp-ts is one of the most popular functional libraries in this language, chances are you will use it to build functional programs.

Hopefully, this article can help you transpose your Scala knowledge regarding for comprehension expressions into a TypeScript functional project, using the do notation.

Please, let me know if this is of any help :)

Thank you for reading this far, and have a wonderful day!

Using fp-ts

Case 1

for {
  a <- foo()
  b <- bar(a)
  c <- baz(b) if b >= 42
} yield (b + c)
Enter fullscreen mode Exit fullscreen mode
pipe(
  O.Do,
  O.bind('a', () => foo()),
  O.bind('b', ({ a }) => bar(a)),
  O.filter(({ b }) => b >= 42),
  O.bind('c', ({ b }) => baz(b)),
  O.map(({ b, c }) => b + c)
)
Enter fullscreen mode Exit fullscreen mode

Case 2

for {
  a <- foo()
  _ <- bar()
  b <- baz(a)
  _ <- qux(b)
} yield (b + 1)
Enter fullscreen mode Exit fullscreen mode
pipe(
  I.Do,
  I.bind('a', () => foo()),
  I.chainFirst(() => bar()),
  I.bind('b', ({ a }) => baz(a)),
  I.chainFirst(({ b }) => qux(b)),
  I.map(({ b }) => b + 1)
)
Enter fullscreen mode Exit fullscreen mode

Takeaways

  • Start the do notation with Do + bind
  • To discard the result, e.g. _ <- foo(), use chainFirst
  • To use a filter, e.g. x <- foo() if y, use filter + bind
  • End the do notation (yield x) with map

Using fp-ts-contrib

Case 1

for {
  a <- foo()
  b <- bar(a)
  c <- baz(b) if b >= 42
} yield (b + c)
Enter fullscreen mode Exit fullscreen mode
pipe(
  Do(O.Monad)
    .bind('a', foo())
    .bindL('b', ({ a }) => bar(a))
    .done(),
  O.filter(({ b }) => b >= 42),
  O.chain(({ a, b }) => Do(O.Monad)
    .bind('c', baz(b))
    .return(({ c }) => b + c)
  )
)
Enter fullscreen mode Exit fullscreen mode

Case 2

for {
  a <- foo()
  _ <- bar()
  b <- baz(a)
  _ <- qux(b)
} yield (b + 1)
Enter fullscreen mode Exit fullscreen mode
Do(I.Monad)
  .bind('a', foo())
  .do(bar())
  .bindL('b', ({ a }) => baz(a))
  .doL(({ b }) => qux(b))
  .return(({ b }) => b + 1)
Enter fullscreen mode Exit fullscreen mode

Takeaways

  • Start the do notation with Do(monadInstance)
  • To discard the result, e.g. _ <- foo(), use doL if you require a previous value, or do if you do not
  • End the do notation (yield x) with return if you need to compute some value, or done if you want to preserve the "context" object containing all the references
  • To use a filter, e.g. x <- foo() if y, end the do notation with done, then use filter, then chain with a new do notation, with chain and Do(monadInstance)

More information regarding this do notation on the following article: Do syntax in TypeScript.

Discussion (0)