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
Doandbind:
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,
chainis 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.filterand a predicate. - At this point, we still have a
Option<{ a: string, b: number }>, but we need to go back in aDobuilder. For that, the trick is to chain the option we have with a new option that we will get with a newDobuilder. - By chaining, we have access to the previous references
aandb. Be careful though, as they are not available in the newDobuilder. - 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(), usedoLif you require a previous value, ordoif you do not - End the do notation (
yield x) withreturnif you need to compute some value, ordoneif 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, withchainandDo(monadInstance)
More information regarding this do notation on the following article: Do syntax in TypeScript.

Top comments (0)