Composable Callbacks

A Promise implementation in under sixty characters

Aldwin Vlasblom
5 min readDec 20, 2017

You’ve heard it before: callbacks don’t compose. I beg to differ. In this article, we will build an abstraction with similar composition and flow control capabilities to Promises, but using only functions that take callbacks — the supposed antithesis of composition. We will then use our newfound abstraction to solve the Async Problem.

Let’s start by thinking about how we define functions for a moment. A regular addition function might be defined as such:

Regular addition function

But we can also define it slightly differently, as a function that takes a single argument, and returns a function that takes another argument, which in turn returns the result of adding the two arguments together:

Curried addition function

Many of you will recognise the latter as being the “curried” variant of the first. You can read up on currying in Chapter 4 of the Mostly Adequate Guide.

Defining the function this way unlocks some new ways of using the function. For example, we can easily define a new add5 function by applying add to 5, for mapping over an Array, for example:

Useful currying

We are going to define all of our functions in the curried way, which is the first step to enabling the composition of callbacks.

Let’s take a basic example of an asynchronous program using callbacks:

Callback hell

When we do it like this, it sends us straight to callback hell. Let’s see what we can do after creating a curried version of readFile. We will also simplify the callback a little bit by taking away the error argument. We’ll get back to this near the end of this article.

Curried readFile

By now you might be wondering what those ::-comments are doing above every function. They are type definitions in a neat type language called Hindley Milner. The HM language is very succinct when describing curried functions in particular. If you take a short moment to understand how it works, it’ll help you to see more clearly what’s happening with our functions. You can read more about it in Chapter 7 of the Mostly Adequate Guide.

You may also have noticed that I’ve shuffled the argument order a little bit. This is to be more optimised for partial application. This new definition of readFile allows us to partially apply it, and not pass the callback yet.

Partially applying readFile

Let’s look at what we’ve created here:

  1. readText: A partial application of readFile, with the encoding. We can just reuse it without having to pass 'utf8' everywhere.
  2. step1: A partial application of readText. The only argument left now is the actual callback. So step1 becomes a function that takes a callback to which the contents of input.txt will be passed.
  3. step2: A function that takes some input and uses it to read a file with a name containing said input. It doesn’t actually read any files though, it just partially applies readText again and returns the function waiting for a callback.
  4. step3: Just an alias to console.log for illustrative purposes. It used to be nested inside the callback to step2.

Now if we study the signatures of each of these functions, we’ll find that they all plug into each other quite nicely. step3 could be used as a callback for step2, and the entirety of step2 could be used as the argument to step1. Doing that would require a lot of nesting, but we can define a helper function which “flattens” the nesting. Let’s call it then ;)

A “then” helper

Our then function takes three arguments:

  1. A transform function, which receives a value and produces a function waiting for its callback. Our step2 actually fits this description.
  2. A function still waiting for its callback. Our step1 fits this.
  3. A callback. Our step3 fits this one.

What’s cool about this function, is that when we partially apply it with its first two arguments, we get back a type that can be used again as a second argument to then. This is what will allow us to stick multiple “steps” next to one another, rather then nested within each other.

You might have noticed from the signature that there are three instances of
(a -> Undefined) -> Undefined. It would become a lot more clear if we gave this particular type a special name, and use that in our types instead. Let’s create a simple alias (Future) for the callback-taking function. The constructor for this type has no implementation: it just returns the input (because it’s an alias). But it will help to make our code clearer. Let’s redefine our then function with more clearly named types.

A clearer “then” helper

This new then function is exactly the same as the previous one, but it suddenly becomes a lot clearer what it’s doing: It takes a function that creates a Future, and it takes a Future and finally returns a new Future. Talking in these terms, step1 is a Future of a String, and step2 returns a Future of a String, after taking a String.

Equipped with our then function and type alias, we can rewrite our callback hell program.

Callback heaven

Our then function is actually doing mathematically accurate flat-mapping. Just see what happens if we replace Future by Array in the type signature. The abstract interface behind flat-map-able types is called “Monad” (because the mathematicians beat us to it).

The fact that we could use program as an argument to then in order to compose a bigger program means we have achieved our goal of creating composable callbacks.

Let’s get back to this console.error-bit though, because we’ve lost the ability to manually handle errors. We can add that back, simply by having our function take two callbacks instead of one.

Callback heaven with rejection branch

The then function in our last example gives us similar asynchronous function composition and flow control benefits to those that Promises give us, in a function that can be written in under sixty characters:

const then = f => m => l => r => m (l) (x => f (x) (l) (r))

It even does away with many of the problems that Promises have. But it does leave some things to be desired, such as good performance and stack safety. For our purpose though, it will do just fine: to solve the Async Problem and demonstrate that callbacks are just as composable as synchronous code.

The original version of Fluture was pretty much implemented like this: yes, then is called chain, and it’s a method rather than a function. But despite that (and the many performance optimisations introduced over the course of seven major versions) the essence remains the same.

Solving the Async Problem

The Async Problem is a little challenge set to identify how well an abstraction allows the user to break an asynchronous algorithm into small, manageable sub-problems. To conclude this post, let’s dive into the depths and solve it with callbacks.

Solution to the Async Problem

--

--

Responses (2)