loading...

Mastering Hard Parts of JavaScript: Callbacks I

internettradie profile image Ryan Ameri Updated on ・5 min read

I'm currently undertaking JavaScript: The Hard Parts v2 course at Frontend Masters. It is a brilliant course taught by the amazing Will Sentance. The course goes over the following key concepts:

  1. Callbacks & Higher order functions
  2. Closure (scope and execution context)
  3. Asynchronous JavaScript & the event loop
  4. Classes & Prototypes (OOP)

In this tutorial series, I will go over the exercises given in each section, provide my own solution and provide a commentary as to how I came to that solution. This first part deals with Callbacks.

Callbacks are an inherently fundamental concept in JS, as most everything from closure to asynchronous JavaScript is built upon them. Prior to my introduction to JS, I had never encountered higher ordered functions (a function that can take another function as input, or return a function) so I initially found the concept very confusing. Thankfully, with lots of practice, I was able to get a good handle on callbacks. I'd encourage you to implement your own solutions first before looking at mine and then compare and contrast. There are certainly many different ways of solving these exercises and mine are definitely not necessarily the best. My solutions are all available on github and you are very welcome to fork the repo to work on your own or, if you have found a better way of solving these, send a PR.

If you are new to JS or have a hard time getting your head wrapped around callbacks, I think going through these exercises will help you master the concept. For more information, Will's slides for the course can be found here(pdf).

Exercise 1

Create a function addTwo that accepts one input and adds 2 to it.

console.log(addTwo(3)) should output 5
and
console.log(addTwo(10))
should output 12

Solution 1

function addTwo(num) {
  return num + 2;
}

The most simple exercise. It gives us a nice comforting feeling knowing that we know how to use functions. Don't worry, things will get interesting soon!

Exercise 2

Create a function addS that accepts one input and adds an "s" to it.

console.log(addS("pizza")); should output pizzas and console.log(addS("bagel")); should output bagels

Solution 2

function addS(word) {
  return word + "s";
}

Another easy function. Good reminder that + is an overloaded operator in JS that can work with strings and numbers.

Exercise 3

Create a function called map that takes two inputs:
an array of numbers (a list of numbers)
a 'callback' function - a function that is applied to each element of the array (inside of the function 'map')
Have map return a new array filled with numbers that are the result of using the 'callback' function on each element of the input array.

console.log(map([1, 2, 3], addTwo)); should output [ 3, 4, 5 ]

Solution 3

function map(array, callback) {
  const newArr = [];
  for (let i = 0; i < array.length; i++) {
    newArr.push(callback(array[i]));
  }
  return newArr;
}

Now this is more interesting! We are basically re-implementing a simple version of the native Array.prototype.map() function here. I decided to use a basic for loop here as most people should be familiar with it. I think this is probably the most important exercise in the series, if you can get head around this, you've basically gotten callbacks!

Exercise 4

The function forEach takes an array and a callback, and runs the callback on each element of the array. forEach does not return anything.

let alphabet = "";
const letters = ["a", "b", "c", "d"];
forEach(letters, function (char) {
  alphabet += char;
});
console.log(alphabet);

should output abcd

Solution 4

function forEach(array, callback) {
  for (let i = 0; i < array.length; i++) {
    callback(array[i]);
  }
}

Another reimplementation of a native Array method. Notice the difference with map, map returns an array, forEach doesn't return anything so whatever needs to happen needs to take place in the body of the callback function.

Exercise 5

Rebuild your map function, this time instead of using a for loop, use your own forEach function that you just defined. Call this new function mapWith.

console.log(mapWith([1, 2, 3], addTwo)); should output [ 3, 4, 5 ]

Solution 5

function mapWith(array, callback) {
  const newArr = [];
  forEach(array, (item) => {
    newArr.push(callback(item));
  });
  return newArr;
}

Using your own previously defined function in this manner is very powerful. It allows you to get to grips with how functions exactly work. Now when you use a library such as lodash or underscore, you can imagine how the underlying function is implemented.

Exercise 6

The function reduce takes an array and reduces the elements to a single value. For example it can sum all the numbers, multiply them, or any operation that you can put into a function.

const nums = [4, 1, 3];
const add = function (a, b) {
  return a + b;
};
console.log(reduce(nums, add, 0))

should output 8.

Solution 6

function reduce(array, callback, initialValue) {
  let accum;
  if (Object.keys(arguments).length > 2) {
    accum = initialValue;
  } else {
    // InitialValue not provided
    accum = array[0];
    array.shift();
  }

  forEach(array, (item) => {
    accum = callback(accum, item);
  });
  return accum;
}

Ah reduce! One of the most misunderstood yet powerful functions in JS (and more broadly in functional programming). The basic concept is this: You have an initial value, you run the callback function on every item in an array, and assign the result to this initial value. At the end, you return this value.

The other gotcha with reduce is that the initialValue parameter is optional, the caller might provide it or not. If it is provided, we should use its value as the initial accumulator of our array. If it's not provided, we should consider the first element of the array as the accumulator. Here we test the number of arguments provided by checking Object.keys(arguments).length and proceed to set our accumulator accordingly.

Notice how we used our own forEach function, we could have of course also used the native array.forEach(), with the same behaviour.

Edit: Thanks to Jason Matthews (in the comments below) for pointing out that my previous solution (assigning initialValue to itself) could have unintended side effects. By assigning to a new variable, we have made the function pure.

Edit 2: Thanks for Dmitry Semigradsky for picking up a bug in the reduce implementation!

Exercise 7

Construct a function intersection that compares input arrays and returns a new array with elements found in all of the inputs. BONUS: Use reduce!

console.log(
  intersection([5, 10, 15, 20], [15, 88, 1, 5, 7], [1, 10, 15, 5, 20])
);

Should output [5, 15]

Solution 7

function intersection(...arrays) {
  return arrays.reduce((acc, array) => {
    return array.filter((item) => acc.includes(item));
  });
}

Combining reduce and filter results in a powerful function. Here, if acc is not provided as a param, it is set to the first array, and we are not providing it as an argument. So in subsequent calls we just filter the arrays to return items that were also included in the acc` array.

Notice the use of ...arrays, here we are using the rest parameters because we don't know how many arguments will be supplied to the function.

Posted on by:

internettradie profile

Ryan Ameri

@internettradie

Re-discovering frontend development. Professional translator & interpreter. Amateur powerlifter. BLM. He/him/his

Discussion

pic
Editor guide
 

Being that this is intended to teach "the hard parts of JavaScript" I think you should take some care to not teach bad habits inadvertently as well.

Specifically exercise 6. You're reusing initialValue as your accumulator. Firstly, unless you're doing some kind of crazy micro-optimization, just don't ever reuse function inputs in your functions. Ever. You will undoubtedly create unintended side effects that waste countless hours debugging.

Consider the case where the array is not an array of primitives. You assign the initialValue to array[0] if an initialValue wasn't defined. Seems innocent enough. In this case though, it's a reference and not a copy. Now on each iteration of the forEach you're modifying the first element in the input array! Surely that's not what's intended and, especially for your target audience here, you're going to create a very frustrating future experience for them.

Now also consider the case where initial value is defined but is not a primitive value. If I was inputting something I wanted to use later in my code it would be unexpectedly changed!

Instead you should take care to create a copy of array[0] and initialValue when setting the value for your accumulator.

 

You're 100% correct there. I should have assigned initialValue to a new variable to make sure the function has no side effects. Bad miss on my part. Thanks for pointing out 😊

 

maybe then update the post as not everyone is going to fully read the comments section :)

 

Current reduce implementation is wrong - it will handle first item twice if you will not set initialValue.

 

Thanks for picking that up! Should be fixed now 😀

 

if (Object.keys(arguments).length > 2) {

I think you can just use arguments.length. Cheers!

better yet, typeof initialValue !== 'undefined' so you avoid touching arguments at all

initialValue can be undefined

yes...but accum = initialValue loses its purpose if it is.