HomeBlogAbout

Abstraction and composition

Oct 1 2023 · 6 min read

The callback hell problem

If you’re familiar with Node.js, you’ve probably heard of callback hell.

It’s an issue that arises when we have a lot of codependent asynchronous logic in our code like this:

asyncFn1(() => {
  asyncFn2(() => {
    asyncFn3();
  });
});

You can see that this gets messy quickly - there is a lot of nesting.

We want the three functions to be called one after the other, but if we tried to do it like this, the functions would run all at the same time:

asyncFn1();
asyncFn2();
asyncFn3();

How do we write that codependent logic without creating a callback hell?

It was commonly advised to extract parts of the code into their own named functions:

const doThing = (cb) => {
  asyncFn1(() => {
    asyncFn2();
    cb();
  });
};

doThing(asyncFn3);

While splitting programs into functions is often great, not every piece of code deserves its own function.

If we always separated our async logic like this, we’d end up with plenty of functions that don’t do much apart from calling other functions.

The nesting is still there, it’s just hidden. To find out what’s really going on in the code, we’d have to go through multiple functions which don’t do much.

So what’s a better solution? An async flow control library like async.js:

async.series([asyncFn1, asyncFn2, asyncFn3]);

Now we can execute async functions subsequently without writing unreasonably nested code, or hiding the nesting in silly helper functions.

We can still split our code into helper functions if we want to:

const doThing = (cb) => {
  async.series([asyncFn1, asyncFn2, cb]);
};

async.series([doThing, asyncFn3]);

But now we have a choice. We’re free to compose our code however we want.

Two types of abstraction

The previous example leads me to believe that there are two types of abstractions.

The first type is composable abstractions. Those are functions that we can compose into a bigger program, functions like doThing.

The second type is abstractions that allow us to compose code in new ways. Those include things like the async.js library, or JavaScript Promises, and async/await which replaced it later.

Let’s call those higher order abstractions. A common theme between them is allowing us to write the flow of our programs in more expressive ways.

jQuery and React

A different example would be comparing something like jQuery to React.

jQuery made developer’s lives easier by eliminating various inconsistencies between browsers, and providing some handy utilities.

However, at the end of the day, developers were still writing the same type of DOM reading and updating code that they did before.

On the other hand, React revolutionized how we think about building UI’s.

It gave us components, props, state, and most importantly, declarative UI updates.

Those are the primitives that allow us to build user interfaces (and other things like emails, 3d scenes, pdfs, or videos) in a more expressive way.

The cure for nesting

I think most of us agree that nesting code is bad for readability. In the case of async logic, we fixed it by rethinking how we express flow control.

Let’s look at a different example of deeply nested code:

int calculate(int bottom, int top)
{
  if (top > bottom)
  {
    int sum = 0;

    for (int number = bottom; number <= top; number++)
    {
      if (number % 2 == 0)
      {
        sum += number;
      }
    }

    return sum;
  }
  else
  {
    return 0;
  }
}

This is an example of nested code from a YouTube video by Code Aesthetic. The way he proposes to denest it is by using two methods - extraction and inversion.

Extraction is simply extracting code into separate functions like we did in the doThing example earlier.

Inversion means changing the order of the conditional statements so that we can take advantage of early return.

This is the fixed code:

int filterNumber(int number)
{
  if (number % 2 == 0)
  {
    return number;
  }

  return 0;
}

int calculate(int bottom, int top)
{
  if (top < bottom)
  {
    return 0;
  }

  int sum = 0;

  for (int number = bottom; number <= top; number++)
  {
    sum += filterNumber(number);
  }

  return sum;
}

For a performance sensitive case, I think the author did a great job.

However, I’d like to take a step back and rethink how the code was written in the first place.

What we’re essentialy doing here is we sum a range of numbers that we filter based on whether a number is even.

This is how we would implement that functionality in Elm:

sumEvenFromRange start end =
  range start end
  |> filter isEven
  |> sum

As you can see, there is no nesting and the implementation even resembles its description in natural language.

We can tell what this code is doing immediately by looking at it without reading the implementation of the functions used.

That’s because we use composable idioms - range, filter, isEven, and sum.

Previously we were hiding what our code was doing in the filterNumber function (Filter based on what? Does the function return a boolean?).

In the Elm example we put together composable abstractions using a higher level abstraction - the pipe operator. This is what the code would look like without it:

sumEvenFromRange start end =
  sum(filter isEven (range start end))

It’s shorter but there is nesting. Nesting that we can avoid by changing how we express the flow of our program using the pipe operator.

For the sake of completeness, if we want our code to behave exactly like Code Aesthetic’s example, we also need to handle the special case of bottom being greater than top.

This is the complete example with imports added:

import List exposing (sum, filter, range)
import Arithmetic exposing (isEven)

sumEvenFromRange start end =
  range start end
  |> filter isEven
  |> sum

calculate bottom top =
  if (top < bottom) then
    sumEvenFromRange bottom top
  else
    0

Native language features

When we zoom out, we realize that things like functions or conditional statements belong to the category of higher order abstractions too.

In fact, a lot of those abstractions are implemented as language features.

Take a look at this TypeScript library called TS-Pattern:

import { match, P } from "ts-pattern";

const myList = ["a", "b", "c"];

match(myList)
  .with([], () => {
    console.log("The list is empty");
  })
  .with([P._], ([x]) => {
    console.log(`The list has one element: ${x}`);
  })
  .otherwise(() => {
    console.log("The list has multiple elements");
  });

This is pattern matching. It allows us to model the flow of our program in a more expressive way than with just if statements.

Pattern matching is implemented in some other languages as a native feature. For example, Python has the match operator:

my_list = ["a", "b", "c"]

match my_list:
  case []:
    print("The list is empty")
  case [x]:
   print(f"The list has one element: {x}")
  case _:
   print("The list has multiple elements")

Summary

I believe there are two types of abstractions:

  • composable abstractions
  • higher order abstractions

We’re usually concerned with writing the first type. The second type is covered by

  • the standard library with functions like filter
  • external libraries like async.js, React, or TS-Pattern
  • native language features like Promises or the pipe operator

The expressiveness and thus readibility of our code depends on how we combine those abstractions with our own composable abstractions.

How powerful and expressive a language feels depends largely on the quality of its higher order abstractions.

Hiding parts of our program in different functions isn’t always the best way to make the code more readable.

Sometimes the best refactors happen when we rethink how we approach the problem.