Arrays and Iteration Methods
Prerequisites assumed: you already know arrays are objects with length-ordered properties, understand closures and this binding, and can read prototype-based method dispatch. This chapter focuses on how the iteration methods behave internally, what that costs, and where the common errors live.
One Chain, Many Passes
map, filter, find, some, every, reduce, flatMap, and friends all walk the array from index 0 upward, invoking a callback per element and skipping holes. They return fresh structures (except reduce, which returns anything you seed, and the short-circuiting some/every/find, which stop early).
A chain is not one pass; it is one pass per method:
const nums = Array.from({ length: 1_000_000 }, (_, i) => i);
const big = nums
.filter((n) => n % 2 === 0) // 1,000,000 callbacks
.map((n) => n * 3) // ~500,000 callbacks
.reduce((a, b) => a + b, 0); // ~500,000 moreFor hot paths, a single reduce or a plain loop halves allocations and callbacks. Correctness first, though: chained map/filter is usually the right trade until profiling says otherwise.
Holes, Deleted Elements, and Density
Iteration methods skip empty slots but do not skip undefined:
const arr = [1, , 3]; // hole at index 1
arr.forEach((x) => console.log(x)); // 1, 3
arr.map((x) => x ?? 0); // [1, 0, 3] — hole becomes undefined in the result
const dense = arr.filter(() => true); // [1, 3] — holes removedNever use delete arr[i]; it makes a hole rather than shortening the array. Use splice or filter.
Mutators vs. Returns
The biggest source of silent bugs in chains: some methods mutate in place, others return copies.
| Method | Mutates? | Returns |
|---|---|---|
| `map`, `filter`, `flat`, `flatMap` | no | new array |
| `slice` | no | new array |
| `concat` | no | new array |
| `sort`, `reverse`, `splice`, `fill` | **yes** | the same array |
| `push`, `pop`, `shift`, `unshift` | **yes** | length / element |
const a = [3, 1, 2];
const b = a.sort(); // sort returns a — no copy!
a === b; // true
a.sort((x, y) => x - y); // comparator, not needed here but mandatory for numbers ≥ 10Also remember the default sort compares as strings: [10, 9].sort() gives [10, 9]? No — it gives [10, 9]... it gives [10, 9] only by accident; [10, 2] sorts to [10, 2] as strings ("10" < "2"). Always pass a comparator for numbers.
The .toSorted(), .toReversed(), .with(), and .toSpliced() methods (ES2023) give non-mutating equivalents — prefer them in shared or React-style state.
thisArg and Read-Only Contracts
Every callback method accepts a second thisArg parameter. Arrow callbacks ignore it because arrows bind this lexically — pass it only with regular functions. Conversely, the callbacks must be pure with respect to the array: mutation during map/filter/forEach produces implementation-adjacent, spec-defined-but-surprising results (insertions may or may not be visited; deletions cause skips).
const urls = ['a', 'b'];
urls.forEach(async (u) => save(u)); // WRONG: forEach does not await; all saves race
// Correct: Promise.all(urls.map(save))forEach cannot be broken out of with return — that returns from the callback only. Use some (truthy stops), every (falsy stops), or for...of with break.
const index = arr.findIndex((x) => x > 3); // stops at first match, returns index or -1Choosing the Right Method
- Transform every element →
map - Subset →
filter - Fold to one value →
reduce - First match / test →
find/findIndex/some/every(short-circuit — cheaper thanfilter()[0]) - Flatten →
flat(depth)/flatMap - Group →
Object.groupBy/Map.groupBy(ES2024) - Lazy, possibly infinite, or aborted sequences → generators (later chapter)
const byCategory = Object.groupBy(products, (p) => p.type);diagram mermaid caption: Callback methods as one pass per stage alt: Flow diagram showing an array flowing through filter, then map, then reduce, each stage creating a new intermediate array.
caption: Callback methods as one pass per stage
alt: Flow diagram showing an array flowing through filter, then map, then reduce, each stage creating a new intermediate array.
flowchart LR
A["nums<br/>(1M items)"] --> B["filter<br/>(1M callbacks)"]
B --> C["new array<br/>(~500k items)"]
C --> D["map<br/>(~500k callbacks)"]
D --> E["new array<br/>(~500k items)"]
E --> F["reduce<br/>(~500k callbacks)"]
F --> G["single number"]exercise Q: You replace nums.filter((n) => n > 0)[0] with nums.find((n) => n > 0). Why is this both faster and clearer, and what edge case do the two forms share? A: find short-circuits at the first match instead of building a full intermediate array, and the intent ("first positive") is explicit rather than implied by indexing. Shared edge case: both return undefined when no element matches, so you must still handle the not-found case. exercise
estimate minutes: 35 estimate
Spotted a mistake, or code that does not work?