Left Fold

The Task Monad in Javascript: pure asynchronous effects you can compose

Task (aka Future) is a data type that lets you create and compose asynchronous functions in a pure functional way. If you aren’t using Tasks already, you might have come across them in Brian Lonsdorf’s wonderful Professor Frisby material, or in Jack Hsu’s excellent blog post on The Little Idea of Functional Programming.

Here’s an example of how they can be used:

const {prop} = require('ramda')

const httpGet = url => new Task((reject, resolve) => {
    request(url, (err, data) => { err? reject(err) : resolve(data)})
})

const myTask = httpGet('http://example.com/data.json')
    .map(JSON.parse)
    .map(prop('url'))
    .chain(httpGet)
    .map(JSON.parse)

myTask.fork( //the request isn't sent until we call .fork
    err => console.error(err),
    data => renderData(data)
)

Task is often described as a Lazy Promise. While Promises start their computations as soon as they are created, Tasks don’t do anything until you call the .fork method. When you call fork, you’re essentially calling the (reject, resolve) => function passed into the Task constructor. This triggers a fork in your code, where synchronous operations continue to happen after the fork, and the Task computation can happen asynchronously from that point in time.

In this post I want to focus on how Tasks work from first principles, but if you want to read more about how they compare with Promises, try:

Fork

To understand how Tasks work, lets take a look at how you could start with a node-style callback-based function, and evolve it to try to achieve caller control and composition.

request(url, (err, data)=>{
    if(err) handleError(err)
    else handleData(data)
})

The caller of request here has no control over the request after they have called the function - they have to write everything into the callback at that point, and move on. Nor can we compose this function with other functions, because we don’t get anything back from it. request performs the HTTP request as a side effect and doesn’t return any data.

Caller Control

We can get a little control over making the request if we write the function to take the arguments one at a time:

const getUrl = url => callback => request(url, callback)
const getImagesJSON = getUrl('http://example.info/images.json')
getImagesJSON((err, data)=>{
  if(err){
    //do something with err
  }
  else {
    //do something with data
  }
})

We’ve handed some control to the calling code: we’ve separated constructing the request from actually performing it. This is useful because now we have a function that knows how to get some JSON from http://example.info/images.json, but it doesn’t need to know what to do with the JSON, and we haven’t done anything impure yet - the calling code gets a function that will perform an impure effect, but it has control over if and when it will happen.

But we still can’t easily compose this callback with more functionality, because the (err, data) => node.js callback signature is too awkward. We can’t just pass a function like JSON.parse to getImagesJSON because the node-style callback takes two arguments (err and data) - to get at our data we are forced to wrangle with err in the same function.

Composition

A way to fix this is, if instead of expecting one callback with two arguments, we expect two callbacks with one argument:

const getUrl = url =>
    (reject, resolve) =>
        request(url, (err, data)=>{
            err? reject(err) : resolve(data)
        })

Now when we call getUrl(dataUrl) we get back a function that we can pass our error handler, and our data pipeline to:

const {pipe} = require('ramda')
const request = require('request')

const getImagesMetadata = getUrl('http://example.info/images.json')
const handleError = err =>
        console.error('Error fetching JSON', err)
const handleData = pipe(JSON.parse, renderData)

getImagesMetadata(handleError, handleData)//fire off the request

Now we can compose our handleData function (using a pipe combinator); handleData can focus purely on the happy path because the error handling is done in a separate callback.

A limitation here is that the composition is coupled with triggering the async computation. It would be nice if we could compose getImagesMetadata with JSON.parse somehow, to create a new function that can fetch JSON from a server and parse it, but still give the caller control over triggering it in the same way as getImagesMetadata, as well as the option to keep composing it with more functions. What we need is a special composition function that knows how to compose a normal one-argument function with our (reject, resolve) => function (which we’ll call fork):

// composes `f` with a `fork` function
const taskMap = (f, fork) => //-- we return another fork function
    (reject, resolve) =>
        fork(reject, x => resolve(f(x)))
        // when called, the new fork will run `f`
        // over the value, before calling `resolve`

We take a normal one-argument function f and a fork function like getImagesMetadata, and we return a new fork function that runs f over the success value. If the fork fails, the reject callback is called with some value representing the failure.

Note that we call it taskMap because like .map on Maybes and Arrays, it runs a function over the value in the container (which in this case is the fork function), and returns the next value wrapped up in the container again.

We can use taskMap like this:

//-- a Task function that will fetch some data and parse it
const getImagesMetadata = taskMap(
      JSON.parse
    , getUrl('http://example.info/images.json')
)

//-- we can compose `getImagesMetadata` too!
const {pipe, prop, pluck} = require('ramda')

const getImageUrls  = taskMap(
      pipe(
          prop('images')
        , pluck('url')
      ) //[{url, size, camera, geo}] => ['http://...', ...]
    , getImagesMetadata
)
//-- now we run the Task
getImageUrls(handleError, renderData)

What about when we want one asynchronous action to lead to another, like reading data from one place, processing it, and writing it somewhere? We need to compose a fork-returning function f, with a fork function:

const taskChain = (f, fork) =>
    //we return another fork
    (reject, resolve) => {
        // calling `f` with the eventual value
        // gives us another `fork` function
        // so we call it
        const next = x => f(x)(reject, resolve)
        fork(reject, next)
    }

We call it taskChain because, like the .chain() method of Maybe (and other fantasy-land monads), we’re composing without nesting forks inside forks (which is what would happen if we used taskMap).

We can use it like this:

const {pipe, prop} = require("ramda")
const fs = require("fs")

const writeFile = filename => data =>
        (reject, resolve) =>
            fs.writeFile(
                  filename
                , data
                , 'utf-8'
                , (err, _) => err? reject(err) : resolve(data)
            )

const saveAddressFromUrl = pipe(
    getUrl, //fetch JSON
    taskMap(JSON.parse), //parse
    taskMap(prop('address')), //grab the bit we want
    taskMap(JSON.stringify), //reserialise it
    taskChain(writeFile('address.json')) //save to file
)
saveAddressFromUrl(
    'http://example.info/contact.json'
)(console.error, console.log)

Now this is a matter of taste, but if we implement Task as an Object with methods (as is more usual in javascript), we can use dot-chaining for composition instead:

const Task = fork => ({
  map: f => Task((reject, resolve) => fork(reject, a =>
        resolve(f(a)))),
  chain: f =>
    Task((reject, resolve) => fork(reject, a =>
        f(a).fork(reject, resolve))),
  fork,
})
Task.of = a => Task((_, resolve) => resolve(a))
// other useful methods like `ap` and `bimap`
// are left as as an exercise to the reader

This lets us dispense with the pipe and the point-free style if we want:

(Assuming writeFile is rewritten to use the Object-style Task).

getUrl("http://example.info/contact.json")
    .map(JSON.parse)
    .map(d=>d.address)
    .map(JSON.stringify)
    .chain(writeFile('address.json'))

An important thing to realise is that whether Task is implemented as an object with methods, or as a set of higher-order functions, all we’re really doing under the hood, is composition with this odd-shaped fork function. No magic, no hidden mutable state or side effects, just composing functions.

So what makes Task a monad, is not that it has a .chain() method but that it is a data type for which it is possible to compose together an a -> Task b function with a b -> Task c function, and get an a -> Task c function, rather than a -> Task Task c.

Separating an algebraic data type’s structure from its methods as we have done here, is (I find) a useful exercise for understanding the essence of the data type.†

Static-land is a fantasyland-inspired spec using a function/static method style, instead of the prototype style of fantasyland.

Posted December 13, 2017

author Keith AlexanderWritten by Keith Alexander. Interests include functional programming, web tech, event sourcing and linked data.