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:
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.
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.
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 Maybe
s and Array
s, 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.
Written by Keith Alexander. Interests include functional programming, web tech, event sourcing and linked data.