Practical Guide to Fp‑ts P6: The Do Notation

Table of Contents 🔗
Introduction 🔗
Welcome back to Part 6 of The Practical Guide to fp-ts. So far I've covered the basics of functional programming and fp concepts. Now I will shift to more advanced topics.
In this post, I will formally introduce what a monad is and how we can use the
Do
Notation to simplify writing monadic code.
What is a Monad? 🔗
By now, you may have noticed I haven't said the term monad once in this entire series. This was intentional. The term monad creates a lot of confusion for newcomers to functional programming because there are many interpretations for what a monad is.
But even though I haven't said the term monad, you've been using monads throughout this entire series. Option is a monad. Either is a monad. Task is a monad.
So what is a monad?
A monad is any type that has the following instance methods:
-
of
-
map
-
chain (flatmap)
-
ap
In addition it the implementation of these methods must satisfy the following monadic laws:
- Left Identity:
of(x).chain(f) == of(f(x))
- Right Identity:
of(x).chain(of) = of(x)
- Associativity:
of(x).chain(f).chain(g) = of(x).chain(flow(f, g))
Are Monads Containers? 🔗
A common belief about monads is that they are containers. Some might refer to them as "ships in a bottle". Even though there exist some monads that would satisfy the definition of a container, not all monads are containers.
Lets see why.
When we look at the Option monad, its clear that it operates as a container over a nullable type. Its either
Some(x)
or
None
. Just like how Either is either
Left
or
Right
over some value
x
.

Can we create a type that satisfies monadic properties but isn't a container? Yes we can. Just change the all the option methods to return
None
.
export interface None {
readonly _tag: 'None'
}
export interface Some<A> {
readonly _tag: 'Some'
readonly value: A
}
declare type Option<A> = None | Some<A>
export declare const none: Option<never>
// this option "contains" nothing
const option = {
of: <A>(a: A) => none,
map: <A, B>(fa: Option<A>, f: (a: A) => B) => none,
chain: <A, B>(fa: Option<A>, f: (a: A) => Option<B>) => none,
ap: <A, B>(fab: Option<(a: A) => B>, fa: Option<A>) => none,
}
Now our new "Option" is clearly not a container but it is still a monad. So it is not enough to describe monads as containers.
Instead, I will implore you with @YuriyBogomolov definition of a monad.
The Do Notation 🔗
Understanding monads is key to understanding the Do notation. But before we jump in, lets first understand the motivation for the Do notation.
The most common hurdle people run into when using monads is maintaining variable scope when using the
chain
operator.
Lets build up an example to demonstrate this.
First, lets define 3 functions returning a
Task
monad.
import * as T from 'fp-ts/lib/Task'
// filler values for brevity
type A = 'A'
type B = 'B'
type C = 'C'
declare const fa: () => T.Task<A>
declare const fb: (a: A) => T.Task<B>
declare const fc: (ab: { a: A; b: B }) => T.Task<C>
In order to call
fc
we need to have access to the return values of
fa
and
fb
. If we want to normally chain these set of function calls, we would need to nest our chain calls to keep previous variables in scope.
Like so.
T.task.chain(fa(), (a) => T.task.chain(fb(a), (b) => fc({ a, b }))) // Task<"C">
This is in contrast to what we would normally write, which looks like this:
flow(fa, T.chain(fb), T.chain(fc)) // ❌ "a" will go out of scope
So how can we achieve something that looks similar to the above? We can use the Do notation!
The Do notation is similar to
sequenceT
and
sequenceS
in the sense that you need to provide it an instance. The difference is, sequences require the instance to be of the
Apply
type (
ap
+
map
) while Do requires a
Monad
type (
ap
+
map
+
chain
+
of
).
So lets look at the same code but using the Do notation instead.1
import { Do } from 'fp-ts-contrib/lib/Do'
Do(T.task)
.bind('a', fa()) // task
.bindL('b', ({ a } /* context */) => fb(a)) // lazy task
.bindL('c', fc) // lazy task
.return(({ c }) => c) // Task<"C">
What
Do
does here is, it lets you keep the
bind
the result of each task to a context variable. The first parameter in
bind
is the name of the variable. The second is the value.
You may also notice there are two variants of bind:
bind
and
bindL
. The
L
suffix stands for lazy. In this example, we don't directly provide a
Task
to
bindL
, we provide a function where the parameter is the context and the return value is a
Task
.
And at the very end of the Do notation we add a
return
call. In the previous example, we went from
fa -> fb -> fc
to form the
Task<"C">
. With the Do notation we need to specify what we want to return because just binding variables leaves us in an undefined state.
You can also view this from the imperative lens, where
fa
,
fb
, and
fc
are synchronous functions rather than monads.
declare const fa: () => A
declare const fb: (a: A) => B
declare const fc: (ab: { a: A; b: B }) => C
;() => {
const a = fa()
const b = fb(a)
const c = fc({ a, b })
return c
}
If we wanted to introduce a side effect, say
console.log
, its easy in the imperative world.
;() => {
const a = fa()
const b = fb(a)
console.log(b) // 👈 side effect
const c = fc({ a, b })
return c
}
With Do notation we can do the same with a
do
invocation.
import { log } from 'fp-ts/lib/Console'
Do(T.task)
.bind('a', fa())
.bindL('b', ({ a }) => fb(a))
.doL(({ b }) => pipe(log(b), T.fromIO)) // 👈 side effect
.bindL('c', fc)
.return(({ c }) => c)
Do
is different from
bind
in the sense that it doesn't take a name as its first argument. This means it won't be added to the context.
If you want a more in-depth post about the
Do
notation, check our Paul Gray's post where he covers all the Do methods.
The Built-In Do Notation 🔗
One of the problems with the Do notation from the
fp-ts-contrib
package is its inflexibility. Every
bind
must be a monad representing the instance passed in. This means we can't switch categories from say
Task
to
TaskEither
. In our example, we are limited to
Task
because we used
Do(T.task)
.
If we were to introduce a 4th function that returns a
TaskEither
, we would need to replace our instance with
taskEither
and lift each
Task
into
TaskEither
, which is not ideal because it becomes more verbose.
import * as TE from 'fp-ts/lib/TaskEither'
type D = 'D'
declare const fd: (ab: { a: A; b: B; c: C }) => TE.TaskEither<D, Error>
Do(TE.taskEither)
.bind('a', TE.fromTask(fa()))
.bindL('b', ({ a }) => TE.fromTask(fb(a)))
.doL(({ b }) => pipe(log(b), T.fromIO, TE.fromTask))
.bindL('c', ({ a, b }) => TE.fromTask(fc({ a, b })))
.return(({ c }) => c)
Instead, fp-ts has its own notation for binding where we can switch between different monads with ease.2
pipe(
T.bindTo('a')(fa()),
T.bind('b', ({ a }) => fb(a)),
T.chainFirst(({ b }) => pipe(log(b), T.fromIO)),
T.bind('c', ({ a, b }) => fc({ a, b })),
TE.fromTask,
TE.bind('d', ({ a, b, c }) => fd({ a, b, c })),
TE.map(({ d }) => d),
)
You can see that the advantage of this approach is the ease of switching between different categories of monads. Hence, I strongly recommend you use this notation over the
Do
notation from the
fp-ts-contrib
package.
Conclusion 🔗
The Do notation is a powerful way of writing monadic code that makes it easy to chain functions while at the same time maintaining variable scope. Its inspired by the Haskell Do notation and Scala's for-yield notation.
In Typescript, we can use the Do notation from the
fp-ts-contrib
package or the built in
bind
methods. But there's another notation thats being discussed on the fp-ts Github. It proposes using function generators and along with
yield
syntax to make monadic code look imperative. Its an interesting approach and definitely worth investigating further.
Lastly, if you're interested in my content, be sure to follow me on Twitter.
Until next time.
- Note this comes from the fp-ts-contrib package.↩
- Note
chainFirst
is the equivalent ofdoL
.↩