JAVASCRIPT / ARRAYS
Chaining array methods without intermediate mess
Turn a multi-step array transformation into one readable chain, knowing which links can be chained, which end a chain, and how to order them safely.
What you will learn
- Chain only links that return a new array: filter, map, slice, flat, flatMap, concat
- Spot chain enders by return type: forEach gives undefined, push gives a number
- Order links so filter and slice shrink the data before an expensive map runs
- Insert slice() before sort() or reverse() when the chain starts at a shared array
Understanding Chaining array methods without intermediate mess
A chain works for exactly one reason: filter, map, slice, flat, flatMap and concat leave the array they were called on alone and hand back a brand-new array. The dot you write after a closing parenthesis attaches to that returned array, not to the original, so in readings.filter(...).map(...) the map callback only ever sees the survivors of the filter. That is what lets you delete the const step1, const step2, const step3 ladder: each stage is already described by its callback, and only the final value is worth a name.
What breaks a chain is a link whose return value is not an array. push returns the new length, pop and find return a single element, includes returns a boolean, forEach returns undefined, and reduce returns whatever you accumulated. So the only skill here is knowing each method's return value: as soon as a link returns a non-array, that link has to be the last one, which is why forEach belongs at the end of a chain or nowhere in it at all.
Two links lie about being pure: sort and reverse reorder in place and return the very same array. Mid-chain that is harmless, because the array they receive was just manufactured by map or filter and nothing else can see it; at the head of a chain, data.sort(...) silently reorders data for every other part of the program. Each link is also a separate full pass that allocates a new array, so put the narrowing links first and collapse map-then-flat into one flatMap. When a stage needs a name to be understandable, or two later stages need the same intermediate value, stop the chain and name it — one honest variable beats a six-link chain nobody can follow.
const readings = [
{ station: 'north', c: 21.5, ok: true },
{ station: 'south', c: 30.2, ok: true },
{ station: 'east', c: 18.9, ok: false },
{ station: 'west', c: 26.4, ok: true },
];
const hottest = readings
.filter((r) => r.ok)
.map((r) => ({ name: r.station, f: r.c * 9 / 5 + 32 }))
.sort((a, b) => b.f - a.f)
.slice(0, 2)
.map((r) => `${r.name} ${r.f.toFixed(1)}F`)
.join(', ');
console.log(hottest);
console.log('source untouched:', readings[0].station, readings.length);A method can appear mid-chain only if it returns a new array, so both correctness and safety follow entirely from each link's return value.
Worked examples
Links that cannot be chained past
Shows the return values that terminate a chain, and the error you get for continuing anyway.
const nums = [3, 1, 4, 1, 5];
const fromPush = [1, 2, 3].push(4);
console.log('push gives:', typeof fromPush, fromPush);
const fromForEach = nums.map((n) => n * 2).forEach((n) => n);
console.log('forEach gives:', fromForEach);
try {
nums.filter((n) => n > 2).forEach((n) => n).join('-');
} catch (err) {
console.log('chained past forEach:', err.constructor.name);
}Example explained
Line 1push returns the new length, so a chain continued after it would be calling array methods on the number 4.
Line 2forEach always returns undefined no matter what its callback does, which makes it a terminator rather than a link.
Line 3The join call therefore reads a property of undefined and throws a TypeError at the dot, not inside forEach.
Line 4The filter before it worked fine — the chain was valid right up to the link with the wrong return type.
sort at the head of a chain
Demonstrates that a mutating link only stays safe when the array it receives was freshly created.
const scores = [70, 95, 82];
const topTwo = scores.sort((a, b) => b - a).slice(0, 2);
console.log(topTwo.join(','), '| scores is now', scores.join(','));
const safeSource = [70, 95, 82];
const safeTop = safeSource.slice().sort((a, b) => b - a).slice(0, 2);
console.log(safeTop.join(','), '| safeSource is still', safeSource.join(','));Example explained
Line 1scores.sort(...) reorders scores itself and returns that same array, so the chain's first link edited data that lives outside the chain.
Line 2The trailing slice(0, 2) copies two elements out but cannot undo a reordering that already happened.
Line 3Adding .slice() as the first link gives sort a throwaway copy to wreck, and the source keeps its original order.
Line 4Both chains produce the identical result, so the copy costs one allocation and buys back predictability.
Filter before the expensive link
Shows that link order changes both how much work runs and whether junk values ever exist.
let parses = 0;
const toCents = (s) => { parses += 1; return Math.round(Number(s) * 100); };
const raw = ['19.99', '', '4.50', ' ', '7.25'];
const mapFirst = raw.map(toCents).filter((c) => c > 0);
console.log('map first :', mapFirst.join(','), 'parses:', parses);
parses = 0;
const filterFirst = raw.filter((s) => s.trim() !== '').map(toCents);
console.log('filter first:', filterFirst.join(','), 'parses:', parses);Example explained
Line 1In the first chain map runs over all five entries, so the counter reaches 5 before anything is discarded.
Line 2Number('') is 0, so the blank rows become a real 0 that exists in the intermediate array until the filter removes it.
Line 3Reversing the order lets a cheap string test shrink the array first, so toCents runs three times and never sees a blank.
Line 4The two results are identical, which is why reordering pure links is a free improvement rather than a tradeoff.
Important notes
Each link is another full pass and another allocated array; irrelevant for hundreds of items, but a five-link chain over a million rows builds five million-element arrays, so only then consider folding it into one reduce or loop.
toSorted, toReversed and with (ES2023) return copies and are safe anywhere in a chain, but they are absent from older runtimes, where .slice().sort() is the portable form.
Common mistakes
Continuing a chain after forEach, as in list.forEach(fn).filter(...): forEach returns undefined, so you get a TypeError at the next dot and none of the later stages ever run.
Starting a chain with .sort() or .reverse() on an array received as a parameter or held in state: the chain returns the right answer while the original order is permanently destroyed for every other reader.
Writing a braced map callback without return, such as .map((r) => { r.total * 2 }): every element becomes undefined, the rest of the chain keeps running without an error, and you debug an empty or NaN-filled result far from its cause.
Try it yourself
Change, predict, then run
Build an array of ten product objects with name, price and inStock, then in a single chain produce a string of the three cheapest in-stock names joined by ' | '. Log the original array afterwards to prove no link reordered it.
Open the JavaScript workspaceCheck your understanding
In data.filter((n) => n > 0).sort((a, b) => a - b).slice(0, 3), sort mutates in place — so why does data keep its original order?
- sort only mutates when it is called without a comparator function
- slice(0, 3) at the end copies the result, which undoes the mutation
- filter already returned a new array, so sort reorders that temporary array instead of data
- methods inside a chain operate on internal copies, so mutation cannot escape a chain
Show answer
filter builds a fresh array that only the chain holds a reference to, so sort's in-place reordering lands on that throwaway array; move sort to the front as data.sort(...) and data itself is reordered. The slice option is tempting because copying feels protective, but slice runs after the mutation and copying a result can never undo a change already made to an earlier array.