DEV Community

loading...

Practical Guide to Fp-ts P6: The Do Notation

ryanleecode profile image Ryan Lee Originally published at rlee.dev Updated on ・7 min read

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:

  1. of
  2. map
  3. chain (flatmap)
  4. ap

In addition it the implementation of these methods must satisfy the following monadic laws:

  1. Left Identity: of(x).chain(f) == of(f(x))
  2. Right Identity: of(x).chain(of) = of(x)
  3. 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.

ships in a bottle
Monads are like "ships in a bottle". Taken from the Mostly Adequate Guide to Functional Programming

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,
}
Enter fullscreen mode Exit fullscreen mode

Our new Option is still a monad but it certainly doesn't look like a container anymore because it always returns None.

If this trivial example passes the test for a monad, then what if we add a
console.log to each invocation? Is it still a monad?

const option = {
  of: <A>(a: A) => {
    console.log('of')
    return none
  },
  map: <A, B>(fa: Option<A>, f: (a: A) => B) => {
    console.log('map')
    return none
  },
  chain: <A, B>(fa: Option<A>, f: (a: A) => Option<B>) => {
    console.log('chain')
    return none
  },
  ap: <A, B>(fab: Option<(a: A) => B>, fa: Option<A>) => {
    console.log('ap')
    return none
  },
}
Enter fullscreen mode Exit fullscreen mode

By definition, this still obeys all monadic laws, so its still a monad. But now that I've introduced a side effect, it doesn't look like a container anymore. These side effects are reminiscent of the IO monad; IO represents a synchronous side effect.

The point I'm trying to make here is: monads are not containers over values. Rather, monads are descriptions of effects. Monads allow you to describe where in your program you have side effects (IO, Task, etc...) and where you have effects that are pure and deterministic.

What does this mean for you as a programmer?

You should strive to push all side effects to the very exterior of your program and keep your core domain logic pure. For example, if you're building a webserver, isolate your web controller and database layer as much as possible because thats where the side effects occur.

In the advanced FP world this is accomplished through the use of Free Monads or Tagless Final, but that is out of the scope for this post.

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>
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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),
)
Enter fullscreen mode Exit fullscreen mode

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.


  1. Note this comes from the fp-ts-contrib package. 

  2. Note chainFirst is the equivalent of doL

Discussion (9)

pic
Editor guide
Collapse
iquardt profile image
Iven Marquardt

How do they break the method chain within a do-block, as Option or Either or Cont demands it?

Collapse
ryanleecode profile image
Ryan Lee Author

I'm sorry I don't understand the question. Can you rephrase it?

Collapse
iquardt profile image
Iven Marquardt

The evaluation of the following method chain is prematurely ended in A:

Do(option)
    .bind("a", some(5))
    .bind("b", none) // A
    .bind("c", some(6))
    .return(({a, b, c}) => a + b + c)
Enter fullscreen mode Exit fullscreen mode

You can easily express such control flows with nested computations but regularly not with sequential ones. Do you know how they solved this issue? Do they really break out of the chain or have to go through the entire structure? The latter would be rather inefficient.

Thread Thread
ryanleecode profile image
Ryan Lee Author

The snippets below are functionally equivalent. It will stop at b because its none. I'm not sure if I understand the question yet. At the end of the day its just a series of chains/flatmaps and it will "break out" on the first one that fails.

Do(option)
  .bind('a', some(5))
  .bind('b', none as Option<number>) // cast otherwise it will default to Option<never>
  .bind('c', some(6))
  .return(({ a, b, c }) => a + b + c)
Enter fullscreen mode Exit fullscreen mode

option.chain(some(5), (a) =>
  option.chain(none as Option<number>, (b) =>
    option.map(some(6), (c) => a + b + c),
  ),
)
Enter fullscreen mode Exit fullscreen mode
Thread Thread
iquardt profile image
Iven Marquardt

I don't think you can call both computations equivalent, because the former has a sequential control flow, whereas the latter has a nested one. Do/bind needs to conduct some additional plumbing to allow "short circution" as the nested chain does. And that is exactly my question. How do they do it? With a promise chain I can short circuit by invoking the error callbac, but this only works for the promise type. With Do , however, short circution needs to work for various monads.

Thread Thread
ryanleecode profile image
Ryan Lee Author

Maybe this is what you're talking about?

github.com/gcanti/fp-ts-contrib/bl...

The extra plumbing is just a map operation to keep variables in context. If you want to do it manually here's what it does under the hood.

pipe(
  O.some(5),
  O.chain((a) =>
    pipe(
      O.none as O.Option<number>,
      O.map((b) => ({ a, b })),
    ),
  ),
  O.chain(({ a, b }) =>
    pipe(
      O.some(6),
      O.map((c) => a + b + c),
    ),
  ),
)

Enter fullscreen mode Exit fullscreen mode
Collapse
steida profile image
Daniel Steigerwald

Very nice article, the whole series is.

Just a question, why do you not prefer this import style?

import { either, ioEither, option } from 'fp-ts';

Collapse
ryanleecode profile image
Ryan Lee Author

never tried 😉

Collapse
steida profile image
Daniel Steigerwald

I am using it all the time. It has a better DX.

1) It's more readable.
2) VSCode auto-import it correctly (you can choose it when you write it)
3) It's tree shakable.
4) No cons.