Callback hell

We have learned that callbacks and higher-order functions are two powerful and flexible JavaScript and TypeScript features. However, the use of callbacks can lead to a maintainability issue known as callback hell.

We are now going to write an example to showcase callback hell. We are going to write three functions with the same behavior, named doSomethingAsync, doSomethingElseAsync, and doSomethingMoreAsync:

function doSomethingAsync( 
    arr: number[], 
    success: (arr: number[]) => void, 
    error: (e: Error) => void 
) { 
    setTimeout(() => { 
        try { 
            let n = Math.ceil(Math.random() * 100 + 1); 
            if (n < 25) { 
                throw new Error("n is < 25"); 
            } 
            success([...arr, n]); 
        } catch (e) { 
            error(e); 
        } 
    }, 1000); 
} 
 
function doSomethingElseAsync( 
    arr: number[], 
    success: (arr: number[]) => void, 
    error: (e: Error) => void 
) { 
    setTimeout(() => { 
        try { 
            let n = Math.ceil(Math.random() * 100 + 1); 
            if (n < 25) { 
                throw new Error("n is < 25"); 
            } 
            success([...arr, n]); 
        } catch (e) { 
            error(e); 
        } 
    }, 1000); 
} 
 
function doSomethingMoreAsync( 
    arr: number[], 
    success: (arr: number[]) => void, 
    error: (e: Error) => void 
) { 
    setTimeout(() => { 
        try { 
            let n = Math.ceil(Math.random() * 100 + 1); 
            if (n < 25) { 
                throw new Error("n is < 25"); 
            } 
            success([...arr, n]); 
        } catch (e) { 
            error(e); 
        } 
    }, 1000); 
} 

The preceding functions simulate an asynchronous operation by using the setTimeout function. Each function takes a success callback, which is invoked if the operation is successful, and an error callback, which is invoked if something goes wrong.

In real-world applications, asynchronous operations usually involve some interaction with hardware (for example, filesystems, networks, and so on). The interactions are known as input/output (I/O) operations. I/O operations can fail for many different reasons (for example, we get an error when we try to interact with the filesystem to save a new file and there is not enough space available on the hard disk).

The preceding functions generate a random number and throw an error if the number is lower than 25; we do this to simulate potential I/O errors.

The preceding functions add the random number to an array that is passed as an argument to each of the functions. If no errors take place, the result of the final function (doSomethingMoreAsync) should be an array with three random numbers.

Now that the three functions have been declared, we can try to invoke them in order. We are going to use callbacks to ensure that doSomethingMoreAsync is invoked after doSomethingElseAsync, and doSomethingElseAsync is invoked after doSomethingAsync:

doSomethingAsync([], (arr1) => { 
    doSomethingElseAsync(arr1, (arr2) => { 
        doSomethingMoreAsync(arr2, (arr3) => { 
            console.log( 
                ` 
                doSomethingAsync: ${arr3[0]} 
                doSomethingElseAsync: ${arr3[1]} 
                doSomethingMoreAsync: ${arr3[2]} 
                ` 
            ); 
        }, (e) => console.log(e)); 
    }, (e) => console.log(e)); 
}, (e) => console.log(e)); 

The preceding example used a few nesting callbacks. The use of these kinds of nested callbacks is known as callback hell because they can lead to the following maintainability issues:

  • Making the code harder to follow and understand
  • Making the code harder to maintain (refactor, reuse, and so on)
  • Making exception handling more difficult