my face
paulgray.net

Approximating haskell's do syntax in Typescript

An introduction to Do from fp-ts-contrib

March 27, 2020
javascripttypescripttypeclassesmonadfunctional programmingfp-ts

A Doomed Pyramid

The pyramid of doom is a problem that's plagued callbacks/promises since they were introduced to the Javascript language. If you've used fp-ts for any length of time, you've probably noticed that this problem isn't specific to asynchronous code. For example, this expression which sums three optional numbers:

option.chain(maybeA, a =>
  option.chain(maybeB, b =>
    option.map(maybeC, c =>
      a + b + c
    )
  )
)

This, of course looks similar to how we'd deal with fetching numbers from some external service:

fetchA.then(a =>
  fetchB.then(b => 
    fetchC.then(c => a + b + c)
  )
)

Also, suppose these numbers were stored in either values, and we wanted to compute the sum, or return early with an error:

either.chain(errorOrA, a =>
  either.chain(errorOrB, b =>
    either.map(errorOrC, c =>
      a + b + c
    )
  )
)

Note: You may have noticed that we could have used something like Promise.all to combine all of our fetched numbers. In this case, it's true (disregarding the fact that all executes in parallel), however, as soon as we need to use the output of the previously chained value as input to the next, we're back to nesting chains.

JavaScript has added async await syntax, which helps avoid this problem for promises:

const a = await fetchA;
const b = await fetchB;
const c = await fetchC;

return a + b + c;

This is nice, but it only works for promises; it won't work for option chains, either chains, or any other chainable. It would be nice if we had something like this that would work for all chainable (monad) values.

This pattern happens quite often in functional programming, and certain fp languages even have special syntax sugar for expressing chains like these in a succinct manner, such as Haskell's do notation, or Scala's for comprehension. There have been a few attempts to mimic this syntax in JS. One popular way is to use generators. Generators don't work for all monads, and they have poor typescript support. It would be nice to have something that works with typescript, and supports all monads.

Do is an attempt at this. It uses the builder pattern to collect the successful value from each chainable, and stores them in an object. At each successive step, the result of any chainable from previous values in the chain is accessible, making it possible to prevent nested callback situations, which can help make code clearer.

Do for Option:

Let's get a feel for how Do works by using it with the previous examples:

Here we have 3 Option values each chained successively:

import { option, Option } from 'fp-ts/lib/Option'
const maybeA: Option<number> = ...
const maybeB: Option<number> = ...
const maybeC: Option<number> = ...

option.chain(maybeA, a =>
  option.chain(maybeB, b =>
    option.map(maybeC, c =>
      a + b + c
    )
  )
)

The Do equivalent of this snippet is:

import { Do } from 'fp-ts-contrib/lib/Do'

const result = 
  Do(option)
    .bind("a", maybeA)
    .bind("b", maybeB)
    .bind("c", maybeC)
    .return(({a, b, c}) => a + b + c)

At each bind step, we're passing an identifier, and an Option<number> value. Each step will only continue on if the previous value was present. Finally, the last return statement, the provided values at a, b, and c, are each numbers. The function here can be used to transform the result value. Remember, each step only gets invoked if all of the previous options were defined. For example, let's say that maybeA's value was some(5), maybeB's value was none, and maybeC's value was some(6). The Do chain would stop at the second step, and the result of the entire Do expression would be none.

const result =
  Do(option)
    .bind("a", some(5))
    .bind("b", none)
    .bind("c", some(6))
    .return(({a, b, c}) => a + b + c)
    
result // evaluates to: 'none'

If all of the values were defined, then we'd reach the return, and the result of the expression would be some(sum), where sum is the value returned by the return function:

const result =
  Do(option)
    .bind("a", some(5))
    .bind("b", some(2))
    .bind("c", some(6))
    .return(({a, b, c}) => a + b + c)
    
result // evaluates to 'some(11)'

Do for Either

When using Do with Either values it behaves similarly to Option. Each step will only continue if the value it encounters is a right value. If a left value is encountered, the whole chain "short-circuits," and returns with that left value.

Here's a snippet of code that doesn't use Do:

import { either, Either } from 'fp-ts/lib/Either'
const errorOrA: Either<string, number> = ...
const errorOrB: Either<string, number> = ...
const errorOrC: Either<string, number> = ...

either.chain(errorOrA, a =>
  either.chain(errorOrB, b =>
    either.map(errorOrC, c =>
      a + b + c
    )
  )
)

And here's that same snippet, with Do:

const result =
  Do(either)
    .bind("a", errorOrA)
    .bind("b", errorOrB)
    .bind("c", errorOrC)
    .return(({a, b, c}) => a + b + c)

As stated, if we encounter a left value at any point in the chain, the whole chain will fallback with that left value (since that is the behavior of either's chain function):

const result =
  Do(either)
    .bind("a", right(5))
    .bind("b", left("error getting b"))
    .bind("c", right(10))
    .return(({a, b, c}) => a + b + c)

result // is: left("error getting b")

If every value is right, then we'll reach the end, however, if we encounter a left value at any point in the chain, the whole chain will fallback with that left value:

const result =
  Do(either)
    .bind("a", right(5))
    .bind("b", left("error getting b"))
    .bind("c", right(10))
    .return(({a, b, c}) => a + b + c)

result // is: left("error getting b")

The API

The Do function takes an instance of a Monad typeclass, which handles the chaining of the values that you pass as the second parameter to bind. The type of the typeclass instance also determines the type of values you will be able to chain. For instance, Do(option) will enable you to chain Option values (since option's type is Monad1<"Option">'). Do(either) will enable you to chain Either values (since either's type is Monad2<"Either">). The value returned from Do is a builder that will let you create chains of these monads.

.bind

.bind takes a string identifier, and an instance of the contextual monad. It will extract the "inner" value from the monad and assign it to a context object, which will make it available in subsequent methods:

Do(option)
  .bind("user", some({name: "Bob", age: 54}))
  .return(context => {
    context.user // <-- Bob is stored here
  })

The builder will maintain every key, adding each value to it's context object. Note that in the return function, context.user's and context.manager's types are User, not Option<User>. bind "extracts" the values for us, in the same way that async "extracts" the value out of a promise.

Do(option)
  .bind("user", some({name: "Bob", age: 54}))
  .bind("manager", some({name: "April", age: 46}))
  .return(context => {
    context.user // <-- Bob is stored here
    context.manager // <-- April is stored here
  })

.do

.do is similar to bind, except you don't pass an identifier, and the resulting value isn't stored in the context.

declare function findManagerFor(name: string): Option<User>
Do(option)
  .bind("user", some({name: "Bob", age: 54}))
  .do(findManagerFor("Bob"))
  .return(context => {
    context.user // <-- Bob is stored here
  })

We'd use do here if we only cared that a manager for Bob existed, and not any details about that manager.

.sequenceS

sequenceS (like bind) also extracts values, but underneath the hood uses ap instead of chain, which allows you to combine values in "parallel." One place this is useful is when validating data:

// ISO strings
const startTime: string = "2020-03-27T02:00:00Z"
const endTime: string = "2020-03-27T02:30:00Z"

// a typeclass instance for validation
const validation = getValidation(getMonoid<string>())

// ensures ISO is valid
function isDate(isoString: string): Either<string[], Date> {...}

const result =
  Do(validation)
    .sequenceS({
      start: isDate(startTime),
      end: isDate(endTime)
    })
    .return(context => {
      context.start // type is Date
      context.end   // type is Date
    })

Since we've used sequenceS here, If both startTime and endTime are invalid, result will be a Left containing both of the errors. This is because .sequenceS uses validation's ap, and .bind uses chain. Consider if we used bind instead:

const result =
  Do(validation)
    .bind("start", isDate(startTime))
    .bind("end", isDate(endTime))
    .return(context => {
      context.start // type is Date
      context.end   // type is Date
    })

In this example, if startTime and endTime are invalid, then result will only contain the error from checking startTime, since chain short-circuits.

.let

let is the simplest of these, and just allows us to introduce identifiers without breaking the expression:

Do(option)
  .bind("user", some({name: "Bob", age: 54}))
  .let("count", 1)
  .return(context => {
    context.count // is 1
  })

.doL/.bindL/.sequenceSL/.letL variants

Each of the above methods has an L variant, .doL, .bindL, .sequenceSL, and .letL. These variants work exactly like their counterparts, except that the second parameter is a function (lamda, hence the L suffix) which returns the monad value. The parameter to the function is the context object that Do maintains. This makes it easy to extract & chain monads whose values depend on extracted values from previous steps in the chain.

Here's an adaption of the date validation example from above. Let's suppose that we also want to validate that the startTime is before endTime. For that, we'll need the actual Date values that are extracted from the ISO values. This can only happen if we've succesfully validated the startTime and endTime ISO strings, though:

// ISO strings
declare const startTime: string
declare const endTime: string

// ensures ISO is valid
declare function isDate(isoString: string): Either<string[], Date>
// ensures start is before end
declare function startIsBeforeEnd(start: Date, end: Date): Either<string[], number>

const result =
  Do(validation)
    .sequenceS({ // check ISO strings
      start: isDate(startTime),
      end: isDate(endTime)
    })
    .bindL("startIsBefore", context => {
      // the bindL variant gives us access to 
      // start and end values
      return startIsBeforeEnd(context.start, context.end);
    })
    .return(context => {
      context.start // type is Date
      context.end   // type is Date
    })

.return

return allows us to map the context value to another value (the final value we want to "get out" of the chain). It determines the final type of the entire expression:

const result =
  Do(option)
    .bind("user", some({name: "Bob", age: 54}))
    .return(context => {
      return context.user.age
    })

result // Option<number>

Here, the type of result is Option<number>, since number is the return type of the function passed to .return.

.done

done does the same thing as return, except it doesn't give us a mapper, it just returns the context object:

const result =
  Do(option)
    .bind("user", some({name: "Bob", age: 54}))
    .bind("manager", some({name: "April", age: 46}))
    .done()

result // Option<{ user: User, manager: User }>

Here, the type of result is Option<{ user: User, manager: User }>, since we've bound "user" and "manager" to the context object.

As complexity of your chain grows, so does the added value of Do.

Here's a lengthy involved example which expands on our validation error previously: https://paulgray.net/notes/do-validation/