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:
- Callbacks & Higher order functions
- Closure (scope and execution context)
- Asynchronous JavaScript & the event loop
- 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.
Top comments (13)
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
toarray[0]
if aninitialValue
wasn't defined. Seems innocent enough. In this case though, it's a reference and not a copy. Now on each iteration of theforEach
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]
andinitialValue
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 :)
Done!
Hey Ryan thank you for this post that I just came across. Do you have your previous solution by any chance so that I can better understand what Jason is pointing out in the comment? Is he saying that in your previous solution you just set " let accum = initialValue" ?
Current
reduce
implementation is wrong - it will handle first item twice if you will not setinitialValue
.Thanks for picking that up! Should be fixed now 😀
I think you can just use
arguments.length
. Cheers!better yet,
typeof initialValue !== 'undefined'
so you avoid touchingarguments
at allinitialValue
can beundefined
yes...but
accum = initialValue
loses its purpose if it is.Hi Ryan! I'm pretty new on my learning journey for JavaScript and would have a question regarding exercise 5.
I generally do get the structure of callbacks, but there is one thing I simply cannot wrap my head around:
Why does the anonymous function within the forEach function require the "item" parameter (see below on line 3).
function mapWith(array, callback) {
const newArr = [];
forEach(array, (item) => {
newArr.push(callback(item));
});
return newArr;
}
Since forEach(array, callback) does execute "callback(array[i])" I simply cannot wrap my head around why this solution would not work:
function mapWith(array, callback) {
let arrNew = [];
forEach(array, () => {
arrNew.push(callback());
});
return arrNew;
}
Maybe you could provide some additional insights? Thank you!
Q: " Since forEach(array, callback) does execute "callback(array[i])" I simply cannot wrap my head around why this solution would not work: "
Answer:
The reason it would not work is because callback(array[i]) will not push the resulting element to the output array, as a result you will get 'undefined' because it hasn't pushed the results into the resulting array. It would simply apply the callback on each item/element. so what we need to do is define another callback method in forEach function.
for(let i=0; i<array.length; i++){
output.push(callback(array[i]))
Basically we want to wrap up this entire functionality into callback of forEach function.
Q: " Why does the anonymous function within the forEach function require the "item" parameter"
forEach(array, (item) => {
newArr.push(callback(item));
});
So for every item that we pass in we want it to push the result of calling the callback with that item into the resulting array. so let's say we have array [1,2,3] the item parameter would call 1 first and apply the callback and push to the resulting array and so on...
There are multiple arguments being passed into multiple parameters and it is kind of confusing but if you step through it line by line using console log statements, it will eventually make sense.
Happy Coding!