Patterns And Best Practices In JavaScript: Dealing With Callback Functions

Photo by Carl Heyerdahl / Unsplash

Patterns And Best Practices In JavaScript: Dealing With Callback Functions

As in any other programming language, JavaScript has a number of best practices and associated bad practices. Due to its dynamic properties, JavaScript also has various pitfalls.

Paul Knulst  in  Programming Jun 7, 2022 4 min read

As in any other programming language, JavaScript has a number of best practices and associated bad practices. Due to its dynamic properties, JavaScript also has various pitfalls.

Callback functions are functions that are passed as parameters to other functions and called by them. They are a common design pattern in asynchronous JavaScript development. The basic (not yet optimal) structure of this design pattern is as follows:

function doStuff(callback) {
  /* ... */
  callback();
  /* ... */
}

The doStuff() function expects a function as a parameter and calls it at a specific point in time:

function justLogSomething() {
  console.log('callback called');
}
doStuff(justLogSomething);  // Output: "callback called"

Check The Callback Function Type

Due to the weak typing of JavaScript, a function that expects a (callback) function as a parameter can in principle also be passed any other value (or no value at all). However, calling this supposed function inevitably leads to a type error ("callback is not a function"). Based on the example above, the following calls would be possible, but not advisable:

doStuff(1337);              // type error -> number
doStuff('Paul Knulst');     // type error -> string
doStuff();                  // type error -> undefined

For this reason, it is important to first check the type of the callback parameter inside the function and make sure that it really is a function. This can be achieved using the typeof operator, as shown in the following listing. If this returns the value "function" for the passed parameter, it is a function and nothing stands in the way of calling it:

function doStuff(callback) {
  /* ... */
  if(typeof callback === 'function') {
    callback();
  }
  /* ... */
}

If there are several places within a function where the callback function could be called, this check would have to precede all these places:

function doStuff(callback) {
  /* ... */
  if(someBool) {
    /* ... */
    if(typeof callback === 'function') {
      callback();
    }
  } else {
    /* ... */
    if(typeof callback === 'function') {
      callback();
    }
  }
  /* ... */
}

This can be avoided if, as in the following listing, you carry out the check at the beginning of the function and, in the event that the callback parameter is not a function, simply redefine it with an anonymous empty function. This simple but efficient trick secures all the following calls to the callback function in one fell swoop:

function doStuff(callback) {
  if(!(typeof callback === 'function')) {
    callback = function() {};  // redefinition
  }
  /* ... */
  if(someBool) {
    /* ... */
    callback();
  } else {
    /* ... */
    callback();
  }
  /* ... */
}

With the help of the conditional operator, the whole thing can even be reduced to one line of code:

callback = (typeof callback === 'function') ? callback : function() {};

Parameters Of A Callback Function

Since callback functions are called asynchronously and can neither supply a direct return value nor throw errors, appropriate parameters should be provided for at least these two cases:

  1. one parameter that contains information about the error that occurred in the event of an error,
  2. one parameter that normally contains the result of the asynchronous calculation.

Even if the order of these two parameters does not matter in principle, it has become a convention - especially when developing Node.js modules - to list the error as the first parameter and the result as the second parameter (if there is no error, the first parameters corresponding to zero).

doStuff(function(
  error,            // first parameter: error object
  result            // second parameter: result object
  ) {
});
function doStuff(callback) {
  /* ... */
  var result = null;
  try {
    // code that throws error
  } catch(error) {
    callback(error);
  }
  callback(null, result);
}

You can use a simple if query within the callback function to find out whether an error has occurred:

doStuff(function(error, result) {
  if(error) {
    // error handling
  } else {
    // normal workflow
  }
});

Return Of The Callback Call

In some cases, calling callback functions as shown in the previous examples can lead to unintended program behavior. For example, in the penultimate listing, the callback function is called twice in case an error occurs.

Here is the code again:

function doStuff(callback) {
  /* ... */
  var result = null;
  try {
    // code that throws error
  } catch(error) {
    callback(error);
  }
  callback(null, result);
}

The problem here is that the code after the try-catch block will always be called. Even if the catch block was previously jumped due to an error and the callback function was called there. This is a fact that you can quickly overlook when just reading the source code. For this reason, you should place a "return" before each call of a callback function, which jumps directly out of the calling function.

function doStuff(callback) {
  /* ... */
  var result = null;
  try {
    // code that throws error
  } catch(error) {
    return callback(error, null);
  }
  return callback(null, result);
}

The prerequisite for this is, of course, that the calling function is structured accordingly and the call of the callback function always represents the end of the function.

For example, the following snippet shows the wrong way to do it:

function doStuff(callback) {
  /* ... */
  var result = null;
  try {
    // code that throws error
  } catch(error) {
    return callback(error, null);
  }
  return callback(null, result);
  // the code below will never be executed
  if(foo) {
    /* ... */
  }
}

The Execution Context Of The Callback Function

Particular caution is required when passing functions as callback parameters that access the execution context via this. In this case, you have to take advantage of the bind() function and use it to create a new function that is bound to the desired execution context.

Then this new function is passed as a callback parameter:

var person = {
  name: 'Paul Knulst', 
  printName: function() {
    console.log(this.name);
  }
}
doStuff(person.printName);  // WRONG

var printNameBound = person.printName.bind(person);
doStuff(printNameBound);    // CORRECT

Conclusion

When working with callback functions, several best practices should be considered, including type checking, the order of callback parameters, calling callback functions once, and setting the execution context.

The fundamental question of whether to use promises, generator functions or the async/await combination for asynchronous programming instead of callback functions will be discussed in the following articles.


This is the end of this explanation about the callback function in JavaScript. Feel free to connect with me on Medium, LinkedIn, Twitter, and GitHub.


Did you find this article valuable? Want to support the author? (... and support development of current and future tutorials!). You can sponsor me on Buy Me a Coffee or Ko-Fi . Furthermore, you can become a free or paid member by signing up to this website. See the contribute page for all (free or paid) ways to say thank you!

By Paul Knulst

I'm a husband, dad, lifelong learner, tech lover, and Senior Engineer working as a Tech Lead. I write about projects and challenges in IT.