Functions as callbacks risks

Don't use functions as callbacks unless they're designed for it

Subscribe to my newsletter and never miss my upcoming articles

This article is a summary of Jake Archibald's (Software Engineer at Google) article "Don't use functions as callbacks unless they're designed for it" which posted on 29 January 2021.

The article discusses the point of using functions as callbacks may lead us to unexpected behavior/output which is different from our expectation, let us see how that may happen So without further ado, Let's Get It Started

Safe usage

Let us imagine that we are using the toReadableNumber function (converts input numbers into human-readable string form) from some-library as a callback function that has the following implementation:

/**
 * Convert some numbers into human-readable strings:
 */
import { toReadableNumber } from 'some-library';
const readableNumbers = someNumbers.map(toReadableNumber);

Let's say that toReadableNumber has the following implementation:

export function toReadableNumber(num) {
  // Return num as string in a human readable form.
  // Eg 10000000 might become '10,000,000'

  // magic staff
  return ''
}

So far everything works great until the maintainers of some-library decide to update the implementation of toReadableNumber and add another parameter (base) to convert the number to a readable one with a specific radix with a default value of 10 to make the toReadableNumber function backward-compatible with the old usage.

export function toReadableNumber(num, base = 10) {
  // Return num as string in a human readable form.
  // In base 10 by default, but this can be changed.
  // Eg 10000000 might become '10,000,000'


  // magic staff
  return ''
}

The root of all evil

After the maintainer updated the function and you didn't change anything in your implementation but the following will happen:

// We think of:
const readableNumbers = someNumbers.map(toReadableNumber);
// …as being like:
const readableNumbers = someNumbers.map((n) => toReadableNumber(n));
// …but it's more like:
const readableNumbers = someNumbers.map((item, index, arr) =>
  toReadableNumber(item, index, arr),
);

Actually, besides the number itself, We're also passing the index of the item in the array, and the array itself

By using toReadableNumber as a callback, we have assigned the index to the base parameter which will affect the output of the toReadableNumber function.

The developers of toReadableNumber felt they were making a backward-compatible change but it breaking our code, mainly it isn't some library's fault - they never designed toReadableNumber to be a callback to array.map, they didn't expect that some code would have already been calling the function with three arguments.

Best practice

So the safe thing to do is create your own function that is designed to work with array.map And that's it! The developers of toReadableNumber can now add parameters without breaking our code.

const readableNumbers = someNumbers.map((n) => toReadableNumber(n));

parseInt Example

Let see another example of the result of using functions as a callback, Imagine we have a numList which is a list of string numbers and we'd like to parse its items to integers.

Mmmmm, sounds easy, let us use parseInt 😎!

1_tGa9hX3PoGxZgI7t-q1WqA.gif

image.png

It seems EASY but in case you used parseInt as a callback function, you will be shocked! if you used parseInt as a callback you will get [-10, Nan, 2, 6, 12] while we are expecting [-10, 0, 10, 20, 30], that's because parseInt has a second parameter which is the radix

image.png

Solution

It is better to call the function explicitly instead of passing the function reference directly.

image.png

Linting rules

Using eslint-plugin-unicorn you can add Prevent passing a function reference directly to iterator methods to your set of eslint rules

image.png

Luiz Filipe da Silva's photo

This is an important tip. Thanks for sharing!

Abdallah Hemdan's photo

Thanks for your feedback ❤

Jesús Melendez's photo

Useful information and advice.

Abdallah Hemdan's photo

Thanks, Jesús Melendez for your feedback 🙏😍

@thebarefootdev's photo

"It is better to call the function explicitly instead of passing the function reference directly"

I don't agree necessarily, within functional programming, it is often preferential to chain or pipe transformations. There are a number of ways this can be done but function composition is one of them. In this way, a composed function reference is often passed as an argument.

Of course anonymous functions have their place, but not if the transformation pipe is a long one. Here, in your example you are merely using an anonymous function to call parseInt

However, this is a reference to parseInt as you are passing it in as part of the anonymous function block. So these are the same.

.map(n => parseInt(n)),
.map(parseInt)
Show +1 replies
@thebarefootdev's photo

Abdallah Hemdan

Ah yes that's true. However, you may still pass a reference with a partial applied function if one is to be functional.

image.png

Abdallah Hemdan's photo

@thebarefootdev

Yeah I agree, now we are on the same page thanks for your feedback 😍