Topic: Learning notes

JavaScript Callbacks and Higher-Order Functions: Passing Behavior as an Argument

Build a reusable array transformation to understand callbacks, higher-order functions, map, arrow functions, mutation, and shallow copies.

A loop can double every number in an array. If the next task is to add three to each number, do we need to write the loop again? The traversal is the same; only the operation on each element changes.

While studying this chapter of JavaScript: The Hard Parts, v3, I focused on that difference: how to reuse the traversal and choose the operation when calling the function. It also made the relationship between map(), callbacks, and higher-order functions easier to follow.

Find the repeated steps, then decide which part needs to change. · Source: GIPHY

Pass both data and behavior through parameters

A parameter lets the same function work with different data:

function square(number) {
  return number * number;
}

console.log(square(3)); // 9
console.log(square(5)); // 25

number is a parameter in the function definition; 3 and 5 are arguments supplied by the caller. We don’t need a separate square function for each number, so the calculation stays in one place.

JavaScript functions are values too. We can assign them to variables, pass them to functions, or return them from functions. If a parameter can receive data, it can also receive the operation to perform on that data:

function transformItems(items, transform) {
  const output = [];

  for (const item of items) {
    output.push(transform(item));
  }

  return output;
}

function double(number) {
  return number * 2;
}

function addThree(number) {
  return number + 3;
}

const numbers = [2, 5, 8];
const doubled = transformItems(numbers, double);
const increased = transformItems(numbers, addThree);

console.log(doubled);   // [4, 10, 16]
console.log(increased); // [5, 8, 11]
console.log(numbers);   // [2, 5, 8]

transformItems handles the traversal and creates the output array. Its transform parameter receives the function to use. Passing double the first time and addThree the second lets the same traversal perform two different operations.

transformItems is a higher-order function because it accepts another function as an argument. A function that returns a function is also called higher-order. In this example, double and addThree are callbacks: they are passed to transformItems and called by it. These terms describe how the functions are used; they don’t require a special declaration.

Passing a function versus calling it

In transformItems(numbers, double), there are no parentheses after double, so the function value is passed in. The transform parameter receives it, and transform(item) calls it with the current element.

If we write transformItems(numbers, double(2)) instead, JavaScript calls double(2) first and passes its return value, the number 4, to transform. When the loop tries to call that number as a function, it throws a TypeError.

Passing the function doesn’t move or rename the original double binding. The original name can still be used wherever its scope allows.

Using the model from my earlier execution context notes, the first element is 2: transformItems calls double(2), then resumes after double returns 4 and pushes that value into output. In this example, each callback finishes before the loop continues.

Reuse the traversal while choosing a different operation each time. · Source: GIPHY

From a custom function to map()

The built-in array method map() also calls a supplied function for elements and puts the returned values into a new array:

const numbers = [2, 5, 8];
const doubled = numbers.map((number) => number * 2);

console.log(doubled); // [4, 10, 16]
console.log(numbers); // [2, 5, 8]

The callback here is an arrow function written directly at the call site. map() calls it synchronously; in this example, numbers is unchanged.

transformItems is a teaching example, not a complete implementation of map(). The built-in method also passes the index and original array to the callback, and skips empty slots in sparse arrays. Returning a new array doesn’t guarantee a callback has no side effects: it could still modify the original array, an element object, or other outside state.

Concise arrow functions

When there is one simple identifier parameter, its parentheses can be omitted. If the body is an expression, the braces and return can be omitted too:

const double = (number) => {
  return number * 2;
};

const conciseDouble = number => number * 2;

console.log(double(3), conciseDouble(3)); // 6 6

Both functions return the result. A body with braces, however, doesn’t automatically return its last expression:

const missingReturn = number => {
  number * 2;
};

console.log(missingReturn(3)); // undefined

Keep the parameter parentheses for zero or multiple parameters, and for parameters using defaults or destructuring. The choice isn’t only about length: an arrow function has no this of its own and uses this from its surrounding lexical environment. For example:

const shop = {
  multiplier: 2,
  makeTransformer() {
    return number => number * this.multiplier;
  },
};

const transform = shop.makeTransformer();
console.log(transform(5)); // 10

Here makeTransformer is called as shop.makeTransformer(), so this inside the method is shop. The returned arrow function uses that outer this. For an ordinary function, the value of this depends on how that function is called.

Expressing intent and understanding the steps

Higher-order functions can make a call site read closer to the intended result. For example, select numbers of at least 5 and then double them:

const numbers = [2, 5, 8];
const result = numbers
  .filter(number => number >= 5)
  .map(number => number * 2);

console.log(result); // [10, 16]

The call site makes the filtering and transformation visible without managing an index and push there. This is a more declarative style; writing a loop that specifies each step is more imperative.

The two styles can work together. An abstraction still performs concrete steps underneath, and the right choice depends on whether the team can understand the intent and behavior—not just on the number of lines.

Mutate the original array or create a new one?

reverse(), sort(), and splice() modify the array on which they are called. Their counterparts toReversed(), toSorted(), and toSpliced() create new arrays and don’t directly change the original.

The return values of splice() and toSpliced() also differ:

const numbers = [3, 1, 2];
const sorted = numbers.toSorted((a, b) => a - b);
const removed = numbers.splice(1, 1, 9);

console.log(sorted);  // [1, 2, 3]
console.log(numbers); // [3, 9, 2]
console.log(removed); // [1]

const source = [3, 1, 2];
const changed = source.toSpliced(1, 1, 9);

console.log(source);  // [3, 1, 2]
console.log(changed); // [3, 9, 2]

sorted was created before splice(), so the later change doesn’t affect it. splice() returns an array of removed elements; toSpliced() returns the new array with the change applied. The comparator tells toSorted() to order these numbers ascending.

Keeping the original array can help when preparing a different display order or when other code shares the same array. Whether mutation is appropriate still depends on the task; older environments may also need compatibility handling for the newer methods.

A new array is not a deep copy

When the elements are objects, two arrays can still refer to the same objects:

const people = [{ name: "Ada" }, { name: "Bo" }];
const reversed = people.toReversed();

reversed[1].name = "Mia";

console.log(people.map(person => person.name));   // ["Mia", "Bo"]
console.log(reversed.map(person => person.name)); // ["Bo", "Mia"]
console.log(people[0] === reversed[1]);            // true

The arrays are different, but people[0] and reversed[1] refer to the same object. Changing name through either reference is visible through the other.

To modify these people’s top-level properties separately, we can also copy each object when creating the new array:

const copiedPeople = people.map(person => ({ ...person }));
copiedPeople[0].name = "Lin";

console.log(people[0].name);       // "Mia"
console.log(copiedPeople[0].name); // "Lin"

Object spread is shallow too. If an object contains nested objects, those inner values can still be shared. Which levels need copying depends on the data and the intended change.

A callback isn’t necessarily asynchronous

A map() callback runs synchronously during the map() call. setTimeout(), on the other hand, asks the host environment to run its callback later:

console.log("start");

[1, 2].map(number => {
  console.log(number);
  return number * 2;
});

setTimeout(() => console.log("timer"), 0);
console.log("end");

// start
// 1
// 2
// end
// timer

Even with a delay of 0, the timer callback doesn’t interrupt the synchronous code already running in this example. When a callback runs depends on the function or API that receives it; arrow syntax alone doesn’t make it asynchronous.

References