Iteration and array transformation · Lesson 35 of 77 · project
Project: chaining transformations into a pipeline
About 60 minutes.
By the end of this lesson you can
- Read a chain of array methods top to bottom and say what the data looks like at each step.
- Order the steps of a pipeline so that each one does the least work and has what it needs.
- Sort without corrupting the array you were given, and sort numbers correctly.
- Explain what each link in a chain costs, and when that cost stops being irrelevant.
- Break a chain into named steps when it stops reading like a sentence.
Real work is a pipeline
Why it matters
Almost nothing real is one transformation. A report is: take the orders, keep the paid ones, pull out the fields that matter, put them in order, take the top few. That is four steps, and you now know a method for every one of them.
Because filter, map and slice all return arrays, each one can be called directly on the result of the last. That is chaining, and it is why these methods were designed to return new arrays instead of modifying the original. It is also why forEach — which returns undefined — can never appear in the middle of a chain.
This lesson is the project for the module. There is one exercise, and it is the whole pipeline. Everything you need is something you have already used on its own; the new skill is putting the steps in the right order and keeping the result honest.
Reading a chain
Explanation
A chain is written one method per line, indented under the array. Read it top to bottom and say what you are holding after each line: after the filter, still order objects but fewer of them; after the map, small summary objects; after the sort, the same objects rearranged; after the slice, only the first two.
If you cannot say what you are holding after a line, the chain is too clever and should be broken up. That is not a rule about length — a six-step chain of obvious steps is fine, and a two-step chain where one step does something surprising is not.
When you are debugging a chain that produces the wrong answer, the fastest move is to cut it in half: assign the first two steps to a const, print it, and check whether the data was already wrong before the step you suspected.
Put filter before map
Explanation
Two reasons, and the second is the important one. Filtering first means the map runs on fewer elements, which is a small saving. But filtering first is also often the only order that works: once you have mapped each order down to a customer name and a total, the status field is gone, and you can no longer filter on it. Transformations throw information away, so do your selecting while you still have everything.
Sorting comes after mapping when you sort on a field the map produced, and can come before it otherwise. Slicing goes last, because taking the top three only means anything once the order is decided.
There is one ordering trap worth naming: if your filter depends on a value your map computes, you cannot filter first — and that is usually a sign the map is doing two jobs. Compute the value in the map, then filter on it afterwards, and let the chain read in the order the work actually happens.
sort mutates, and by default it sorts as text
A common mistake
Array.prototype.sort is not like map and filter. It sorts the array IN PLACE and returns that same array, so orders.sort(...) rearranges the caller's data. In the middle of a chain that is harmless, because the array it is sorting was freshly made by the map above it. Called directly on data you were given, it is a bug — and a nasty one, because the damage happens somewhere else in the program.
The fix is to sort a copy: [...data].sort(...) or data.slice().sort(...). Newer engines also offer toSorted(), which returns a new array and never touches the original.
The second trap is the default comparison. With no comparison function, sort converts every element to a string and orders by text, so [10, 9, 100, 1] sorts to [1, 10, 100, 9] — correct alphabetical order for those strings, and useless for numbers. Always pass a comparator for numbers: (a, b) => a - b for ascending, (a, b) => b - a for descending. A comparator returns a negative number when a should come first, a positive number when b should, and 0 when they tie.
One guarantee you can rely on: the sort is stable, so elements the comparator calls equal keep their original relative order. That is what lets you sort by total and trust that two customers with the same total stay in the order they arrived.
What a chain costs, honestly
Explanation
Every link in a chain builds a whole new array. A four-step chain over a thousand orders allocates several thousand-element arrays and walks the data four times. A single for...of loop, or a single reduce, would walk it once and allocate one result.
For the sizes most code deals with — a page of results, a shopping basket, a list of files — that difference is unmeasurable, and choosing the less readable version to save it is a bad trade. Readability is the thing you will spend your time on; a few microseconds is not.
It stops being irrelevant in two situations: very large collections, and code that runs constantly, such as inside another loop or on every frame of an animation. If profiling shows a chain is genuinely hot, the fix is to fuse the steps into one pass. Do that when you have evidence, not in advance — a rewrite that is faster and wrong is worse than a chain.
Name the steps when the chain stops reading like a sentence
A mental model
There is no maximum chain length, but there is a test: can you read it aloud as one sentence? 'Take the paid orders, summarise each one, put the biggest first, keep the top two.' That is a sentence. If reading yours aloud requires stopping to work out what a step produces, split it.
Splitting costs nothing at runtime and buys you names. const paidOrders = ... and const summaries = ... turn the chain into a short story with labelled chapters, and each name is a place a debugger can stop and a reviewer can check. Long chains are not a sign of skill; well-named intermediate steps usually are.
Recap
Recap
filter, map and slice return new arrays, so they chain; forEach returns undefined and cannot. Filter before you map, because mapping throws away the fields you might need to filter on. sort mutates in place and returns the same array, so sort a copy unless the array was made by the step above; and always pass a comparator for numbers, because the default sorts as text. Sorting is stable, so ties keep their original order. Each link allocates a new array, which is almost always fine — fuse steps only when profiling says so.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
A four-step report, and the original left intact
The shape this whole lesson is about. Read it as a sentence: take the paid orders, summarise each one, biggest first, keep two. The last line proves the source data survived.
1const orders = [2 { customer: "Ada", total: 120, status: "paid" },3 { customer: "Bo", total: 40, status: "cancelled" },4 { customer: "Cy", total: 300, status: "paid" },5 { customer: "Di", total: 75, status: "paid" },6];7 8const topPaid = orders9 .filter((order) => order.status === "paid")10 .map((order) => ({ customer: order.customer, total: order.total }))11 .sort((a, b) => b.total - a.total)12 .slice(0, 2);13 14console.log(topPaid);15console.log(orders.length);16console.log(orders[0].customer);- line 9
Filter first: after the map the status field is gone and this test would be impossible.
- line 10
Parentheses around the object literal are required — without them the braces read as a function body and every element would be undefined.
- line 11
b.total - a.total sorts descending. This mutates the array the map just created, which nobody else can see, so it is safe here.
- line 12
slice takes a copy of a range; it does not modify anything.
- line 15
Still 4. Nothing in the chain touched the original array.
What it prints
[{"customer":"Cy","total":300},{"customer":"Ada","total":120}]4Ada
The two sort traps in six lines
First the default text comparison on numbers, then the same sort with a comparator, then proof that sorting a copy leaves the source alone — and what happens when you do not.
1const sizes = [10, 9, 100, 1];2 3console.log([...sizes].sort());4console.log([...sizes].sort((a, b) => a - b));5console.log(sizes);6 7const sorted = sizes.sort((a, b) => a - b);8console.log(sizes);9console.log(sorted === sizes);- line 3
No comparator: every element becomes text, so "1", "10", "100", "9" is the order. Alphabetically correct, numerically useless.
- line 4
a - b is negative when a is smaller, so a comes first: ascending order.
- line 5
Untouched, because both sorts above ran on a fresh copy made by the spread.
- line 8
Now the original IS reordered — sort worked in place on the array itself.
- line 9
true. sort returns the same array it sorted, not a new one. That is the difference from map and filter.
What it prints
[1,10,100,9][1,9,10,100][10,9,100,1][1,9,10,100]true
The same answer as a chain and as one pass
A deliberate comparison so the trade-off is concrete rather than theoretical. Both produce the same total; one is obvious at a glance and walks the data twice, the other walks it once and needs a moment's thought.
1const basket = [2 { item: "pen", price: 2, inBasket: true },3 { item: "pad", price: 5, inBasket: false },4 { item: "clip", price: 3, inBasket: true },5];6 7const chained = basket8 .filter((line) => line.inBasket)9 .map((line) => line.price)10 .reduce((total, price) => total + price, 0);11 12const onePass = basket.reduce((total, line) => {13 if (!line.inBasket) {14 return total;15 }16 return total + line.price;17}, 0);18 19console.log(chained);20console.log(onePass);21console.log(chained === onePass);- line 7
Three passes and two throwaway arrays — and it reads as: the ones in the basket, their prices, added up.
- line 14
The early return is how a reducer skips an element: it returns the accumulator unchanged. Forgetting it here would return undefined and destroy the total.
- line 20
Identical results. Prefer the chain until something measurable says otherwise.
What it prints
55true
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
Why can forEach never appear in the middle of a chain like data.forEach(...).map(...)?
One of these sorts touches the original array and one does not. Predict all three printed lines.
const first = [3, 1, 2];const copySorted = [...first].sort((a, b) => a - b);console.log(first);const second = [3, 1, 2];const inPlace = second.sort((a, b) => a - b);console.log(second);console.log(inPlace === second);
Why is filter usually written before map rather than after it?
Write it yourself
Exercise · stretch · 8 tests
Project: the top spenders report
Write a function called topSpenders that takes an array of order objects — each with customer, total and status — plus a limit, and returns an array of report strings. Include only orders whose status is exactly "paid". Highest total first. Take at most limit entries. Each string is the customer name, a colon and a space, then the total: "Cy: 300". Orders with the same total keep the order they appeared in the input. The array you were given must be exactly as you found it when the function returns — including the order of its elements.
topSpenders(orders, limit) returns an array of at most limit strings of the form customer + ": " + total, containing only paid orders, ordered by total from highest to lowest, with ties in original input order. topSpenders([], 3) and topSpenders(orders, 0) are both []. The input array is neither reordered nor modified — sorting it directly is a failure even though the returned report looks right.
Define topSpenders at the top level of your code — the tests call it by name.
Worth remembering
- Which array methods can appear in the middle of a chain, and which cannot?
map, filter and slice return new arrays, so they chain. sort returns the array it sorted, so it chains but mutates. forEach returns undefined, so it ends any chain with a TypeError.
- Why does [10, 9, 100, 1].sort() give [1, 10, 100, 9]?
With no comparator, sort converts every element to a string and compares the text. Always pass a comparator for numbers: (a, b) => a - b ascending, (a, b) => b - a descending.
- What is the safe way to sort an array you were given?
Sort a copy: [...data].sort(...), data.slice().sort(...), or data.toSorted(...). sort works in place and returns the same array, so sorting an argument changes the caller's data.
- In what order do the steps of a report pipeline usually go?
Filter, then sort, then slice, then map. Filter first because mapping discards fields; map last so strings are only built for the rows that survive.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
MDN — Array (copying methods and mutating methods)
Check there: The 'Copying methods and mutating methods' section lists sort() as mutating the array in place, and map(), filter() and slice() as returning new arrays — which is what makes chaining safe.
Check there: Without a comparator, elements are converted to strings and compared as UTF-16 code unit sequences; the sort is stable; sort returns a reference to the same array.
Check there: slice returns a shallow copy of a portion of the array and does not modify the original.
MDN — Array.prototype.toSorted()
Check there: toSorted is the copying version of sort, returning a new array with the elements in order.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown