JavaScript Junior Developer Interview Questions #2 - Implementing the Array Reducer Method in JavaScript
Or, looking under the hood at all our shiny little toys!
It's that time again, ladies and gentlemen. JavaScript interview questions! As always, Hansford has the answers we need. And boy do we need them today.
I'm gonna be honest. I did some practice questions today that knocked me down a couple pegs. It was no joke. Really made me question some things.
But we don't give up. We never give up. We just give ourselves a minute, then get right back into it.
Implementing Array.reduce in JavaScript
Today's question asks us to implement the Array.prototype.reduce() method in JavaScript.
For those unaware, JavaScript has many methods built into the Array object to make our lives more convenient, and one of them is reduce(). This method takes a function as its first argument, which it then uses to combine together a set of values from the array to create a final return value.
Put less abstractly, it's a way for you to reduce an array down to a simple object that you can work with more directly. Say you were querying an API for an athletes points, and it returned to you an array of their point totals for every game they've played this season. If you want to total out those points, you could use Array.prototype.reduce() to reduce the array down to a single number containing the total.
let pointsArray = fetch('points-from-api');
let totalPoints = pointsArray.reduce( (sum, number)=>sum + number);
pointsDiv.innerHTML = totalPoints;
As you can see, reduce takes our function for reducing the array, clearly by somehow accumulating the values over each iteration through the array's indices--and does all this without even needing an initial value to start with.
So for us to do the same thing, we'll need to create a function that takes a function as an argument.
function reduce(reducer){
}
But how can we work on the array if we don't have access to it within the function? Unfortunately, since we're manually implementing this code, we can't expect it to be a method on every Array object. We'll have to give our version of reduce a copy of the array we're working on.
function reduce(array, reducer){
}
Now we need to accomplish a couple things: Cycle through every element in the array, accumulate the values in the array in the way that the reducer function defines, and return the value of the accumulation.
None of that is trivial.
So to begin, we want to use the most common and safe way to cycle through an array: The foreach loop.
function reduce(array, reducer){
array.forEach( (element, index)=>{
//Reduce the array
}
}
Just a reminder: The forEach method takes a function which has two parameters: The current key, element or value that has been reached in the array, and the index of that key, element or value. In plain English, if you have:
let numbers = [1, 2, 3]
... then numbers.forEach() will cycle through numbers by taking number[0], then number[1], and with each iteration, it gives you both the value of number[x] and the index, x. Interestingly enough, two of the values that reducer functions can take are the value and index of the currently-reached element in an array. This is because the reducer function is meant to be called inside of the forEach function.
function reduce(array, reducer){
array.forEach( (element, index)=>{
reducer(element, index);
}
}
But we also need to accumulate the values that the reducer function returns. Conceptually, the reducer function's job is to perform some operations on an element in an array that convert it into some data that can be merged with other similar data. For example, you might take an object which is contained in an array, extract the values of all its properties, combine them into a string, and then return that. If you have an array of multiple such objects, the reduce() method will do this to all of the elements and then return a string that is the combination of all those strings. So you need a value to keep track of what's returned in each iteration.
function reduce(array, reducer){
let accumulator;
array.forEach( (element, index)=>{
accumulator += reducer(element, index);
}
}
Simple, right? Except, we actually have no way of knowing what the reducer is reducing, so we can't simply use += here. If it's just strings, we could initialize accumulator to a string, and likewise if the values will just be numbers, but if for example reduce() is supposed to be constructing an object from an array of data, += will have unexpected behavior. It's actually better if we allow the user to set their own accumulator within the reducer() method, which means they make the rules for how each given element of the array is combined into that accumulator. All we do is keep handing that same value back into the reducer function when we cycle through the array.
function reduce(array, reducer){
let accumulator;
array.forEach( (element, index)=>{
accumulator = reducer(accumulator, element, index);
}
}
Now, we might normally end things for the reducer() there, but Array.prototype.reduce() actually has one last value that it passes to the reducer method, and that is the original array itself. This allows full freedom for the user to do whatever they want with this method, and is easy enough. Just add the array to the end of the argument list in reducer.
function reduce(array, reducer){
let accumulator;
array.forEach( (element, index)=>{
accumulator = reducer(accumulator, element, index, array);
}
}
Back to reduce itself, which we have a final feature to implement: An initial value. Users should be able to add their own value to the start of the process, so we have to add this feature. The best method would be to simply add an "initial" argument to our reduce() parameter list, and then initialize our accumulator as being equal to that. That way, on our first call of reducer(), we're doing it with the value of initial.
function reduce(array, reducer, initial){
let accumulator = initial;
array.forEach( (element, index)=>{
accumulator = reducer(accumulator, element, index, array);
}
}
DONE. Right?
Wrong!
Two problems.
First, our function still fails tests in which we surprise it by passing something other than an array to it. You may think that's not necessary to test for, since we're essentially mimicking a method of the global JavaScript Array object. The thought process would go, "myArray.reduce() is already an array, or it wouldn't have the reduce method; therefore why test for these cases?" Well, ours isn't a method of the array object, so we have to account for things that OOP give us naturally.
The other issue is, our code doesn't yet work on strings! What the heck!? Strings are normally treated like arrays in JavaScript!
So, let's very quickly implement some guard clauses to protect ourselves from any madness.
if(!array || array.length <= 0){
throw new Error("Error! Empty or undefined array specified!");
return;
}
if(typeof(array) !== "string" && !Array.isArray(array)){
throw new Error(`Error! Cannot reduce ${typeof(array)}`);
return;
}
Our first guard clause checks if the array we've been given is null, undefined or empty. Our second checks if the array is not an array and not a string. If it's neither of these things, we can't reduce it! Our reduce() function uses forEach, a method of the Array class. Do not give us an object, number, boolean or any other data type.
This are of course implemented prior to our forEach call, but there's one last thing to do: Converting strings into arrays.
if(typeof(array) === "string") array = [...array];
And we're done! You may be wondering why we have no guard clauses for cases where the initial value given to reduce() are null or undefined, but that's because Array.prototype.reduce() doesn't do anything about that either. As it stands, if you have an array of integers and call reduce without an argument in the initial parameter, it will work just fine, even if the reducer() sums all the numbers.
//Add up all values in an integer array.
[1, 2, 3].reduce((sum, val)=>sum+val)
/// 6
[1, 2, 3].reduce((sum, val)=>sum+val, null)
// 6
[1, 2, 3].reduce((sum, val)=>sum+val, undefined)
/// NaN
This is because it will simply throw whatever you give it as the initial value into the accumulator for the first iteration of the forEach loop. Notice how we get NaN if we have the initial as undefined. This is because undefined + 1 === NaN, and NaN + any other integer is === NaN.
Another case cracked!
Thanks for checking out Hansford's answers! You can always get in touch with me on twitter Shaquil Hansford, or over email: liuqahs15@gmail.com!