Taming the async beast using Tasks

October 25, 20178 min read

cover

A whole new world

Once upon a time, async code in JavaScript was done using callbacks. XHR, setTimeout, web workers … 🤗

They all used callbacks. And it was great.

I do remember the time when I discovered AJAX back in the day, and how awesome I found jQuery $.ajax.

It was truly enlightening : I could make some magical network requests and fetch some JSON data !

// some old code - before jQuery 3.0!
var xhr = $.ajax({
  url: 'http://someurl.com/data',
  type: 'GET',
  dataType: 'json',
  error: function(err) {
    console.error('Uh oh... ', err)
  },
  success: function(data) {
    console.log('Tada! ', data)
  }
})

It was such a great feeling ! I could even cancel them using xhr.abort().

With these tools, I was like the network master. Much requests. Such control.

It was basic yet extremely useful.

Do you realise that ? I could now get data from across the network, I could fire up many requests at the same, or in a sequence, and thanks to my setTimeout tricks, I could add some delay between them !

Let’s say that I need to get some user data from a list of posts :

function handleError(err) {
  /* do something with the error */
}

function getJSON(url, callback) {
  $.ajax({
    url: url,
    type: 'GET',
    dataType: 'json',
    error: handleError,
    success: callback
  })
}

getJSON('https://myapi.com/posts', function(posts) {
  var id = posts[0].id
  setTimeout(function() {
    getJSON('https://myapi.com/posts/' + id, function(post) {
      var userId = post.userId
      setTimeout(function() {
        getJSON('https://myapi.com/users/' + userId, function(user) {
          // Here is my user data !
        })
      }, 1000)
    })
  }, 1000)
})

It worked pretty well, but it was barely maintainable to be honest.

Promises became the async MVP

Then after many discussions, the Promise/A+ spec landed in JavaScript, and some built-in browser API were shipped with a Promise based API like the Battery Status API or the Fetch API.

It was a revolution for me : No more callback hell ! An unifying API for sequencing asynchronous computations !

The .then keyword looked so idiomatic and easy to understand (I mean we use it almost everyday in the English language).

Let’s chain all the things !

function handleError(err) {
  /* do something with the error */
}

function getJSON(url) {
  return new Promise((resolve, reject) => {
    $.ajax({
      url: url,
      type: 'GET',
      dataType: 'json',
      error: reject,
      success: resolve
    })
  })
}

function wait(duration, value) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(value)
    }, duration)
  })
}

getJSON('https://myapi.com/posts')
  .then(posts => {
    const id = posts[0].id
    return wait(1000, id)
  })
  .then(id => getJSON('https://myapi.com/posts/' + id))
  .then(post => {
    const userId = post.userId
    return wait(1000, userId)
  })
  .then(userId => getJSON('https://myapi.com/users/' + userId))
  .then(user => {
    // Here is my user data !
  })
  .catch(handleError) // don't forget it

This code looks so much better, flatter, everything is nicely chained.

The Promise type let us encapsulate all the intricacies of asynchronous code and make it almost synchronous looking :)

But …

What if I want to cancel my request like I used to be able to do with my good old $.ajax ? 🙁

I can’t.

In the ECMAScript 6 spec, Promises (and their .then handlers) are uncancellable by default.
Promise cancellation was under discussion here but the proposal has been withdrawn.

Did we just lose control over our program ?

In some way, I think so. By using this nice abstraction, I've lost some properties such as cancellation.

So what do we do now ?

If you don’t need to cancel anything, you can still use Promises it’s fine :)

Otherwise, you can use another data type to handle asynchronous tasks : Observables or Tasks/Futures.

Here is a nice resource explaining Observables : RxJS: Observables, observers and operators introduction by @toddmotto

In the next section, I’ll spend more time to explain what are Tasks/Futures.

Introducing Tasks

Let me quote the docs from folktale/data.task :

A Task data structure represents values that depend on time. This allows one to model time-based effects explicitly, such that one can have full knowledge of when they're dealing with delayed computations, latency, or anything that can not be computed immediately.

A common use for this monad is to replace the usual Continuation-Passing Style form of programming, in order to be able to compose and sequence time-dependent effects using the generic and powerful monadic operations.

So a Task is a data structure that represent a time dependent computation. Thanks to them, we can have an api that help us orchestrate all of them.

So far, it really looks like a Promise. But there is a catch :

A common use for this monad […]

It’s a monad. 🤖

Have no fear ! Let’s explore the implications of this ‘bizarre’ word 🤔 :

What does that mean for us ?

According to the Fantasyland spec, a Task is a monad, it means that they respect a set of properties.

If M is our monadic type and m an instance of our monadic type then :

  • M has a type constructor that wraps an underlying type i.e. Task<Error, String>
  • It has a M.unit function (aliased under M.of most of the time) that wraps a value into an instance of the said monadic type M i.e. Task.of(3) has the type Task<Error, Int>
  • It's instance has a .chain(f) function (sometimes called .bind(f) or .flatMap(f) or >>= but forget that 😅 ) that applies a function f to a value and returns a new monadic value (i.e. a Task instance in our case) const task3 = task1.chain(x => task2)

And it must respect these three laws :

  • Left identity : Task.of(x).chain(f) == Task.of(f(x))
  • Right identity : Task.of(x).chain(x => x) == Task.of(x)
  • Associativity: Task.of(x).chain(f).chain(g) == Task.of(x).chain(x => g(f(x))

Now you know what is a monadic type !

But that’s not what is the most important for us. This was for general culture purposes. 🤓

As a monad, a Task is pure .

Why ? Because, contrary to Promises, when you instantiate a Task, nothing happens. The asynchronous computations don't start right away. A Task is lazy.

You need to call .fork(onError, onSuccess) that will effectively run our computation and perform side-effects.

A Task instance only describes a computation. We can compose many tasks before actually doing any side-effect. That’s only when it is forked, that the computation gets executed.

A Promise is eager, and it’s computation runs immediately when you instantiate it. In that sense, a Promise is not pure -- it has internal state and doesn’t respect the principle of referential transparency.

If you want to learn more about the difference between a Task and a Promise, @andrestalz made a really clear explanation in this gist 💯

Also, a Promise instance can have multiple observers (multicast) whereas a Task instance can only have one observer (unicast) per execution (the observer is the one who called .fork).

This means as well that it is easier to have a cancellation system for a Task given that there is only one observer waiting for a result.

Let’s have a look at how to use Tasks.

Using a Task

First things first, we need to install a Task data type.

import Task from 'my-task-data-type'

Now we can create a task using the Task constructor :

const t = new Task((reject, resolve) => {})
// 't' is a Task that does nothing and never resolves or rejects.

We can see that the instantiation is quite similar to Promises. The only difference here is that the rejection handler always comes first for a Task.

Let’s now rebuild our previous example with Tasks, then we will add what we need to make everything cancellable :

// getJSON :: string -> Task<Error, json>
function getJSON(url) {
  return new Task((reject, resolve) => {
    const xhr = $.ajax({
      url: url,
      type: 'GET',
      error: reject, // it rejects on error
      success: resolve // it resolves the json data
    })
  })
}

// wait :: (number, any) -> Task<Error, any>
function wait(duration, value) {
  return new Task((reject, resolve) => {
    setTimeout(() => {
      resolve(value)
    }, duration)
  })
}

// myTask :: Task<Error, json>
const myTask = getJSON('https://myapi.com/posts')
  .chain(posts => {
    const id = posts[0].id
    return wait(1000, id)
  })
  .chain(id => getJSON('https://myapi.com/posts/' + id))
  .chain(post => {
    const userId = post.userId
    return wait(1000, userId)
  })
  .chain(userId => getJSON('https://myapi.com/users/' + userId))

// Now we run it ⚙️
myTask.fork(
  error => handleError(error), // you can't fork without handling rejection
  user => {
    // Here's my user data
  }
)

🥁 … now let’s cancel this thing … 🥁

In each task constructor, we need to return a closure that will cancel the computation when called :

// getJSON :: string -> Task<Error, json>
function getJSON(url) {
  return new Task((reject, resolve) => {
    const xhr = $.ajax({
      url: url,
      type: 'GET',
      error: reject, // it rejects on error
      success: resolve // it resolves the json data
    })
    return () => xhr.abort() // cancellation closure
  })
}

// wait :: (number, any) -> Task<Error, any>
function wait(duration, value) {
  return new Task((reject, resolve) => {
    let timeoutID = setTimeout(() => {
      resolve(value)
    }, duration)
    return () => clearTimeout(timeoutID) // cancellation closure
  })
}

// myTask :: Task<Error, json>
const myTask = getJSON('https://myapi.com/posts')
  .chain(posts => {
    const id = posts[0].id
    return wait(1000, id)
  })
  .chain(id => {
    return getJSON('https://myapi.com/posts/' + id)
  })
  .chain(post => {
    const userId = post.userId
    return wait(1000, userId)
  })
  .chain(userId => {
    return getJSON('https://myapi.com/users/' + userId)
  })

// Now we run it ⚙️

// myExecution :: TaskExecution<Error, json>
const myExecution = myTask.fork(
  error => handleError(error),
  user => {
    // Here's my user data
  }
)

// Stop! ✋
myExecution.cancel()

When we run the task, we get an « Execution object ».

It has a .cancel method that let us stop it at any moment.

Behind the scenes, .cancel calls all the cancellation closures of each task in the Task chain.

That’s it! 🔥

This is all what we need to have asynchronous computations, avoid callback hell at all costs, but at the same time keeping control of side-effects.

You can do many many more things with Tasks. And it's even better if you combine them with other Fantasyland compliant data structures.



author undefined

By Yannick Spark : a Front-end engineer who works remotely at Teacup Analytics.
He likes Functional programming, Nutrition & Fasting, and Remote work.
You should definitely on Twitter 👋