Arrays and Iteration Methods
Prerequisites: this chapter assumes you are comfortable with the previous material on values and types, functions, closures, and this. You should know that arrays are objects with numeric keys, that methods live on Array.prototype, and that arrow functions and ordinary functions differ in this binding. Here we go deep on how array methods actually work, where they differ from each other, and where they misbehave.
The working model
An array in JavaScript is not a distinct data structure at the engine level — it is an object whose keys are strings that look like integers, plus a length property that the engine keeps in sync. Most of the iteration methods share one contract, defined by the spec in terms of an internal "iterator protocol" over indexed elements:
- Read
length. - Visit indices
0throughlength - 1. - Skip holes (empty slots) for most methods.
- Call a callback with
(element, index, array).
Two consequences follow immediately:
- Callbacks get three arguments.
parseInttakes a second radix argument, so['1','7','11'].map(parseInt)produces[1, NaN, 3]. This is the classic trap: you passed a function whose signature does not match the callback contract. - The array is read at call time, not snapshot. Mutating it during iteration changes what later callbacks see.
const nums = [1, 2, 3];
nums.map((n) => n * 2); // [2, 4, 6]
nums.forEach((n, i, arr) => console.log(n, i, arr === nums)); // true
// Holes: forEach skips them, map preserves them
const sparse = [1, , 3]; // length 3, index 1 is a hole
sparse.forEach((n) => console.log(n)); // 1, then 3
console.log(sparse.map((n) => n * 2)); // [2, <1 empty item>, 6]If you need holes treated as undefined, normalize first: Array.from(sparse) produces [1, undefined, 3].
Mutator, accessor, and copy semantics
The methods split into three behavioral families, and confusing them is the most common source of bugs in modern codebases.
| Family | Mutates? | Examples | Return value |
|---|---|---|---|
| Mutators | yes | `push`, `pop`, `shift`, `splice`, `sort`, `reverse`, `fill`, `copyWithin` | usually the array itself or a removed item |
| Accessors | no | `slice`, `concat`, `join`, `indexOf`, `flat` | a new value or array |
| Iteration | no | `map`, `filter`, `reduce`, `find`, `some`, `every`, `flatMap` | new array or scalar |
sort deserves special attention: its default comparator coerces elements to strings, so [10, 1, 100].sort() yields [1, 10, 100] by lexicographic order but [1, 10, 2].sort() yields [1, 10, 2] — while [2, 10, 1].sort() yields [1, 10, 2]. To sort numbers you must pass a comparator: (a, b) => a - b. The comparator must be consistent: return a negative number, zero, or positive number, and always the same answer for the same pair. Inconsistent comparators produce implementation-defined order. ES2019+ guarantees sort is stable, so equal elements keep their relative order.
const people = [
{ name: 'Ada', age: 36 },
{ name: 'Bo', age: 29 },
{ name: 'Cy', age: 36 },
];
people.sort((a, b) => a.age - b.age || a.name.localeCompare(b.name));
// stable tie-break by name within equal agesChoosing the right method
Treat method selection as a decision, not a habit. The wrong choice is usually a performance or clarity bug.
findstops at the first match;filter(...)[0]scans everything and allocates. Preferfind.some/everyshort-circuit;filter(...).length > 0does not.reducecan express almost anything, which is exactly why it should be used sparingly. Areducethat builds an object grouped by key is idiomatic; areducethat reimplementsmapis not.forEachcannot short-circuit and cannot be awaited meaningfully (see below). Usefor...ofwhen you needbreak,continue, orawait.
Iteration methods, generators, and await
Iteration methods are synchronous. arr.map(async (x) => await fetch(x)) returns an array of promises, not awaited results. The common fixes:
const urls = ['/a', '/b', '/c'];
// Sequential: each starts after the previous resolves
for (const url of urls) {
const res = await fetch(url);
console.log(url, res.status);
}
// Concurrent: start all, then await all
const results = await Promise.all(urls.map((u) => fetch(u)));Promise.all fails fast on the first rejection; Promise.allSettled waits for every promise and reports each outcome. forEach with an async callback is almost always wrong: it fires all callbacks concurrently and discards their promises, so errors become unhandled rejections.
Typed arrays and array-likes
NodeList, arguments, and HTMLCollection are array-like: they have length and indices but no array methods. Convert once at the boundary: Array.from(nodeList) or, if no transformation is needed, [...nodeList]. Typed arrays (Int32Array etc.) have most iteration methods but a fixed length and homogeneous numeric elements; push and pop do not exist on them.
Copy methods and frozen state
ES2023 added non-mutating counterparts: toSorted, toReversed, toSpliced, and with. Prefer them whenever the source array is shared state — a Redux store, a React prop, a module-level constant. Note that these are shallow copies: arr.with(0, {...obj}) copies the array, not the objects inside it. For genuinely immutable structures you need to copy the mutated elements too, or reach for a persistent-data-structure library.
Edge cases worth memorizing
new Array(3)creates a sparse array with three holes;[3]creates one element. Never use theArray(len)form to pre-fill — useArray.from({ length: n }, (_, i) => ...).[10, 1, 3].sort((a, b) => a - b)is O(n log n) worst case; for already-nearly-sorted data a manual insertion pass can beat it, but profile before optimizing.indexOfuses strict equality and cannot findNaN. UseincludesforNaNandfindIndexwith a predicate for objects.reducewithout an initial value uses element 0 as the accumulator and throws on an empty array. Always pass an initial value unless the non-empty invariant is enforced upstream.
Exercise
What does [1, 2, 3].map(parseInt) return, and why?
Show answer
[1, NaN, NaN]. map passes (element, index, array), so parseInt receives the index as its radix argument: parseInt('1', 0) is 1 (radix 0 is treated as absent), parseInt('2', 1) is NaN, and parseInt('3', 2) is NaN.
Exercise
You need the first user over age 30 from a 100,000-element array. Why is find preferable to filter, and when would filter still be right?
Show answer
find short-circuits at the first match and allocates nothing; filter scans all 100,000 elements and allocates a result array. filter is right when you genuinely need all matches, or when you will reuse the filtered array multiple times so the single allocation pays for itself.
Exercise
Rewrite const sorted = [...arr].sort(cmp) using a single method, and state one behavioral difference.
Show answer
const sorted = arr.toSorted(cmp). Both produce a new sorted array, but toSorted also converts holes to undefined during the copy, whereas [...arr] preserves holes — so the two can differ on sparse arrays.
Spotted a mistake, or code that does not work?