Equivalent of Scala's for-comprehension using fp-ts
Benoit Ruiz
Posted on February 17, 2022
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]
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]
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
- fp-ts v2.8 or above
- Or, fp-ts-contrib v0.0.2 or above
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>
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>
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
andbind
:
import * as O from 'fp-ts/Option' import { pipe } from 'fp-ts/function' pipe( O.Do, O.bind('a', () => foo()) )
-
Calling
foo
, then usingbindTo
:
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):
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))
)
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))
)
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)
)
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)))
)
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)
)
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)
)
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
)
Then, we can add the a <- foo()
step:
pipe(
I.Do,
+ I.bind('a', () => foo())
)
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())
)
Here, we still have the same type for this expression (i.e.
IO<{ a: string }>
) since we ignored whateverbar()
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))
)
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))
)
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)
)
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)
)
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
Then comes the first step of this expression, a <- foo()
. For this, we can use the bind
method:
Do(O.Monad)
+ .bind('a', foo())
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))
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)
+ )
+)
In the fp-ts world,
chain
is the same thing asflatMap
.
Here are some explanations to understand what is happening there:
- We end the first do notation with
.done()
, which yieldsOption<{ 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 aDo
builder. For that, the trick is to chain the option we have with a new option that we will get with a newDo
builder. - By chaining, we have access to the previous references
a
andb
. Be careful though, as they are not available in the newDo
builder. - We use the
.return(...)
method to yield anOption<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)
)
)
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
The next step, a <- foo()
, is pretty straightforward:
Do(I.Monad)
+ .bind('a', foo())
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())
Then, we can use bindL
to implement the b <- baz(a)
part:
Do(I.Monad)
...
+ .bindL('b', ({ a }) => baz(a))
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))
The last step, yield (b + 1)
, can be achieved using the return
method:
Do(I.Monad)
...
+ .return(({ b }) => b + 1)
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)
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)
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)
)
Case 2
for {
a <- foo()
_ <- bar()
b <- baz(a)
_ <- qux(b)
} yield (b + 1)
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)
)
Takeaways
- Start the do notation with
Do
+bind
- To discard the result, e.g.
_ <- foo()
, usechainFirst
- To use a filter, e.g.
x <- foo() if y
, usefilter
+bind
- End the do notation (
yield x
) withmap
Using fp-ts-contrib
Case 1
for {
a <- foo()
b <- bar(a)
c <- baz(b) if b >= 42
} yield (b + c)
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)
)
)
Case 2
for {
a <- foo()
_ <- bar()
b <- baz(a)
_ <- qux(b)
} yield (b + 1)
Do(I.Monad)
.bind('a', foo())
.do(bar())
.bindL('b', ({ a }) => baz(a))
.doL(({ b }) => qux(b))
.return(({ b }) => b + 1)
Takeaways
- Start the do notation with
Do(monadInstance)
- To discard the result, e.g.
_ <- foo()
, usedoL
if you require a previous value, ordo
if you do not - End the do notation (
yield x
) withreturn
if you need to compute some value, ordone
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 withdone
, then usefilter
, then chain with a new do notation, withchain
andDo(monadInstance)
More information regarding this do notation on the following article: Do syntax in TypeScript.
Posted on February 17, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.