Collections and iteration
Arrays and the Methods That Matter
Why sort rearranges the array you passed it, why [10, 9, 1] sorts to [1, 10, 9], and what reduce is for beyond adding numbers up.
Someone hands you a list of scores and asks for it in order. You write
scores.sort(), print the result, and get [1, 10, 9]. The order is
wrong and so is the original array, which three other functions are now reading.
Both problems come from the same place: the array you passed in is the array that changed, and the comparison ran between strings, not numbers. Neither is a bug, and neither is obvious from the name.
Everything below runs on Node 22 or newer in a .mjs file. The comments
after each call are the output, not a description of the output.
map, filter and forEach all traverse once; only two of them return
These three methods walk the array and run your function on each element. The difference is what they do with the return value of that function:
const prices = [12, 5, 30, 8];
const withTax = prices.map((price) => price * 1.2);
const cheap = prices.filter((price) => price < 10);
console.log(withTax); // [ 14.399999999999999, 6, 36, 9.6 ]
console.log(cheap); // [ 5, 8 ]
console.log(prices); // [ 12, 5, 30, 8 ]
map builds a new array of the same length, where element i
is whatever you returned for element i. filter builds a new
array of length zero to n, keeping elements whose callback returned
something truthy. Neither touches the original, and the original is still
[12, 5, 30, 8] after both calls.
The floating point artefact in withTax is worth a glance. It is the same
representation problem as 0.1 + 0.2, arriving through multiplication instead
of addition. Prices in a real system belong in integer cents for exactly this reason.
forEach looks like the same family and is not. It returns
undefined, always, no matter what your callback returns:
const labels = [];
const result = prices.forEach((price) => labels.push(price * 2));
console.log(labels); // [ 24, 10, 60, 16 ]
console.log(result); // undefined
So const doubled = prices.forEach(...) gives you a variable holding
undefined, and the mistake is hard to spot because forEach
really did run. If you find yourself writing forEach with an array declared
one line above it, map is almost always what you meant.
reduce is for turning many values into one value of any shape
The usual introduction to reduce is summing numbers, which is the least
interesting thing it does. The real contract is that it takes an accumulator and one
element at a time, and whatever it returns becomes the accumulator for the next element.
The accumulator does not have to be a number:
const votes = ['salt', 'pepper', 'salt', 'oil', 'salt', 'oil'];
const tally = votes.reduce((counts, word) => {
counts[word] = (counts[word] ?? 0) + 1;
return counts;
}, {});
console.log(tally); // { salt: 3, pepper: 1, oil: 2 }
A tally is the shape reduce was built for. You are walking a list and
producing a single object whose keys you do not know until you have seen the data.
map cannot express it because the output length has nothing to do with the
input length, and filter cannot express it because the output is not a
subset of the input. Any time the answer is "one thing, built from all the things",
reduce is the tool, and the accumulator can be a number, a string, an array,
a Map, or an object.
The second argument matters more than people expect. Leave it out and
reduce uses the first element as the starting accumulator:
try {
[].reduce((acc, n) => acc + n);
} catch (error) {
console.log(error.constructor.name + ': ' + error.message);
}
// TypeError: Reduce of empty array with no initial value
An always-pass-an-initial-value rule avoids that crash and removes a second problem: without an initial value, a one-element array never calls your callback at all, so the body you carefully wrote never runs. Pass the seed.
What reduce is bad at is being the only method in a sentence. A
reduce that gathers into an array is a map plus a
filter with extra steps and less readable intent:
const numbers = [1, 2, 3];
console.log(numbers.reduce((acc, n) => acc.concat(n * 2), [])); // [ 2, 4, 6 ]
console.log(numbers.map((n) => n * 2)); // [ 2, 4, 6 ]
Same output. The second line tells the next reader what is happening before they have
read the callback. Reach for reduce when the shape changes, not when the
values do.
find stops at the first match; filter always walks the whole array
find and filter take the same predicate and differ in when
they stop and what they give back. The cost difference is proportional to how early the
match is, which is why it matters on long arrays:
const events = [
{ id: 'a1', ok: false },
{ id: 'b2', ok: false },
{ id: 'c3', ok: true },
{ id: 'd4', ok: true },
];
let calls = 0;
const first = events.find((event) => {
calls += 1;
return event.ok;
});
console.log(first.id, 'predicate calls:', calls); // c3 predicate calls: 3
calls = 0;
const all = events.filter((event) => {
calls += 1;
return event.ok;
});
console.log(all.map((event) => event.id), 'predicate calls:', calls);
// [ 'c3', 'd4' ] predicate calls: 4
Two useful matches, four elements, and find ran the callback three times
while filter ran it four. On a 50,000-row array with the match in the first
hundred rows, that is the difference between a hundred checks and fifty thousand. It is
also the difference in return type, which is the part that causes bugs:
const users = [{ id: 1 }, { id: 2 }];
console.log(users.find((user) => user.id === 2)); // { id: 2 }
console.log(users.find((user) => user.id === 9)); // undefined
console.log(users.filter((user) => user.id === 9)); // []
find gives you one element or undefined, so the caller has to
handle the miss. filter gives you an array that may be empty, and
[][0] is also undefined, so code that pipes
filter(...)[0] is doing find's work with two traversals and no
advantage. findIndex is the same scan returning a position, and it uses
-1 rather than undefined for a miss, which is one more distinct
miss value to remember. some and every are the shape you want
when the answer is a boolean; both stop early where they can.
sort rearranges the array you handed it, and compares strings by default
Start with the mutation, because it is the part that spreads. sort
returns the same array object it was called on:
const original = [3, 1, 2];
const returned = original.sort();
console.log(original); // [ 1, 2, 3 ]
console.log(original === returned); // true
So scores.sort() in a rendering function reorders the state array for
every other function that reads it. The fix is to copy first, and
[...scores].sort(compare) is the idiom. If you need the copy for anything
else, scores.slice() does the same job with an older name.
Now the comparison. With no comparator, sort converts each element to a
string and compares the strings:
console.log([10, 9, 1].sort()); // [ 1, 10, 9 ]
console.log(['banana', 'Apple', 'cherry'].sort()); // [ 'Apple', 'banana', 'cherry' ]
console.log([10, 9, 1, 100, 21].sort((a, b) => a - b)); // [ 1, 9, 10, 21, 100 ]
"10" comes before "9" because the first character
"1" is less than "9". That is the whole bug, and it will bite
you again with dates stored as ISO strings if the strings are not zero-padded.
The comparator must return a negative number, zero, or a positive number. Subtracting
works for numbers and is the common case. For objects, compare a field, and remember that
a - b on two undefined values gives NaN, which
sort treats as zero, so missing fields pile up unpredictably at the front.
const files = [
{ name: 'b.js', size: 10 },
{ name: 'a.js', size: 2 },
{ name: 'c.js', size: 7 },
];
console.log([...files].sort((x, y) => y.size - x.size).map((f) => f.name));
// [ 'b.js', 'c.js', 'a.js' ]
console.log(files.map((f) => f.name));
// [ 'b.js', 'a.js', 'c.js' ]
The last line is the point of the spread. The sort produced the order you asked for and left the input alone.
slice copies a range; splice removes a range and edits the array
The names differ by one letter, which is a cruelty the language has never apologised
for. slice reads. splice writes.
const queue = ['a', 'b', 'c', 'd'];
console.log(queue.slice(1, 3)); // [ 'b', 'c' ]
console.log(queue); // [ 'a', 'b', 'c', 'd' ]
const removed = queue.splice(1, 2);
console.log(removed); // [ 'b', 'c' ]
console.log(queue); // [ 'a', 'd' ]
slice(start, end) returns a new array covering the half-open interval
[start, end). Negative indexes count from the end, so
queue.slice(-2) is [ 'c', 'd' ], and
queue.slice() is a full shallow copy. splice(start, count)
removes count elements starting at start, returns them in a new
array, and mutates the array in place. It also accepts extra arguments to insert in the
gap, which is how people implement an "insert at position" that quietly reallocates
everything.
The failure mode is row.splice(0, 1) written where
row.slice(0, 1) was meant: in a React render, in a
reduce accumulator, anywhere the same array is read twice. The habit that
prevents it is to reach for the non-mutating method first and switch to the mutating one
only when you have decided that the array should change for everyone.
flat and flatMap differ by one level and one step
flat concatenates nested arrays up to a depth you specify. The default
depth is 1:
const deep = [1, [2, [3, [4]]]];
console.log(deep.flat()); // [ 1, 2, [ 3, [ 4 ] ] ]
console.log(deep.flat(2)); // [ 1, 2, 3, [ 4 ] ]
console.log(deep.flat(Infinity)); // [ 1, 2, 3, 4 ]
flatMap is map followed by flat(1), and that
single level of flattening is what makes it useful. Writing a callback that returns an
array of zero or more results is a common way to filter and transform at once:
const pairs = [[1, 2], [3, 4], [5]];
console.log(pairs.flatMap((pair) => (pair[0] > 2 ? [pair[0]] : [])));
// [ 3, 5 ]
Each callback returns [] or a one-element array, and
flatMap discards the empty ones. The equivalent with two methods is
map then filter, which allocates the intermediate array and
walks the list twice. It is not flat with a map attached; the depth is fixed
at one, so a callback returning a nested array hands you the nesting intact.
Sparse arrays have holes, and methods disagree about visiting them
A hole is an index with no property behind it. You create one by skipping a value in an array literal, by assigning past the end of an array, or by deleting an element:
const holes = [1, , 3];
console.log(holes.length); // 3
console.log(1 in holes); // false
console.log(Object.keys(holes)); // [ '0', '2' ]
let calls = 0;
holes.forEach(() => { calls += 1; });
console.log(calls); // 2
The length counts the hole, in reports that nothing is there, and
forEach skips it. map skips it too and preserves the hole in the
output, while for...of visits it as undefined:
const holes = [1, , 3];
console.log(holes.map((n) => n * 2)); // [ 2, <1 empty item>, 6 ]
for (const value of holes) console.log(value);
// 1
// undefined
// 3
console.log(holes.includes(undefined)); // true
console.log(holes.indexOf(undefined)); // -1
That last pair is a genuine trap: includes and indexOf use
different equality rules, so the same array answers yes and no to the same question.
includes uses SameValueZero, which treats a hole as undefined
and treats NaN as equal to itself. indexOf uses strict equality,
which does neither.
Where holes actually come from in application code is new Array(3),
which creates three holes rather than three values. If you want three zeroes, say so:
Array(3).fill(0). For an index sequence,
Array.from({ length: 3 }, (_, index) => index) gives you
[0, 1, 2] with no holes.
Write a function summariseOrders(orders) that takes an array of
{ id, customer, totalCents, status } objects and returns a single object
with this shape:
{ count: 4, revenueCents: 12980, topCustomer: 'ana', statuses: { paid: 3, refunded: 1 } }
The count includes every order. The revenue counts only orders with status
'paid'. The top customer is the one with the largest single paid order,
or null when nothing was paid. Do not mutate the input array or any of
its objects.
Then answer two questions. When no order is paid, what should
revenueCents be, and which array method forces you to decide that? And
if the caller wants the orders sorted by totalCents descending, why is
sorting inside summariseOrders a mistake?
Show the solution and the reasoning
The count and the status tally are two passes over the same data with different
jobs, so they read better as two passes than as one clever
reduce.
function summariseOrders(orders) {
const statuses = orders.reduce((tally, order) => {
tally[order.status] = (tally[order.status] ?? 0) + 1;
return tally;
}, {});
const paid = orders.filter((order) => order.status === 'paid');
const revenueCents = paid.reduce((sum, order) => sum + order.totalCents, 0);
let topCustomer = null;
let largest = -Infinity;
for (const order of paid) {
if (order.totalCents > largest) {
largest = order.totalCents;
topCustomer = order.customer;
}
}
return { count: orders.length, revenueCents, topCustomer, statuses };
}
const orders = [
{ id: 1, customer: 'ana', totalCents: 4990, status: 'paid' },
{ id: 2, customer: 'bo', totalCents: 7990, status: 'paid' },
{ id: 3, customer: 'ana', totalCents: 900, status: 'refunded' },
{ id: 4, customer: 'cy', totalCents: 3990, status: 'pending' },
];
console.log(summariseOrders(orders));
console.log(orders.map((order) => order.id));
{
count: 4,
revenueCents: 12980,
topCustomer: 'bo',
statuses: { paid: 2, refunded: 1, pending: 1 }
}
[ 1, 2, 3, 4 ]
count is orders.length because counting is not a
transformation. Running reduce over the array to add one to a counter
works and tells the reader less.
revenueCents is where the empty case has to be decided.
reduce with an initial value of 0 returns
0 for an empty list, which is the right answer for a sum: the total of
nothing is nothing. The same call without the 0 throws
TypeError: Reduce of empty array with no initial value. So the initial
value is not decoration, it is the answer you are committing to for the empty case.
The tally is a reduce because the output is one object whose keys come
from the data. Note that it starts from {}, and note what happens if an
order has status: 'toString': the expression
tally[order.status] ?? 0 reads the inherited Object.prototype
function instead of undefined. Status values are controlled by your own
code, so this is a theoretical problem here, and it becomes a real one as soon as the
keys are user-supplied. The next lesson covers the fix.
topCustomer uses a plain loop on purpose. The reduce
version is shorter and worse:
const top = paid.reduce(
(best, order) => (best === null || order.totalCents > best.totalCents ? order : best),
null,
);
const topCustomer = top === null ? null : top.customer;
Both are correct. The loop version keeps the accumulator and the tie-breaking rule
visible in one screen, and it returns null directly instead of forcing a
second step to unwrap. reduce is not automatically the better tool because
it is shorter.
Nothing in the function sorts. Ordering is a presentation decision that belongs to
the caller, and the caller may want the orders by date, by customer, or in the order the
database returned them. Sorting inside the summary also means the input array gets
reordered for every other reader if you forget the copy, which is the mutation trap from
earlier in the lesson. If the caller wants a sorted view, they write
[...orders].sort((x, y) => y.totalCents - x.totalCents) and the summary
stays a pure function of its input.
Progress
Mark this lesson done and it stays marked in this browser.