JavaScript Iterators & Array Methods.
Every .map, .filter, for...of, spread, and destructure rests on two interfaces — and a handful of short, almost identical functions on Array.prototype. Build them once and you'll never look at the docs again.
What this guide covers
- The iterator protocol — what makes something "iterable"
- Building your own iterator from scratch
- Generators — iterators with autopilot
- Array methods, step by step:
forEach,map,filter - Predicate methods:
some,every,find,findIndex,includes - Reduction:
reduce,reduceRight - Search & access:
indexOf,flat,flatMap - Mutators vs non-mutators — the danger list
- Lazy iteration — why generators beat arrays for big data
The iterator protocol
Before we touch .map, we need to understand what makes for...of work on arrays, strings, Maps, Sets, and DOM NodeLists — but NOT on plain objects. The answer is two tiny contracts.
Iterator
An object with a next() method that returns { value, done }. Call next() repeatedly until done is true.
Iterable
An object with a [Symbol.iterator]() method that returns an iterator. for...of calls this method to start looping.
Fig. 1 — Two interfaces, three properties total. That's the entire foundation of for...of, spread, destructuring, Array.from, and more.
Why this matters
The reason for (const x of [1,2,3]) works is that arrays have [Symbol.iterator] baked in. The reason for (const x of {a:1}) throws is that plain objects don't. Add the symbol method and any object becomes iterable.
Make a plain object iterable
Smallest possible example: a range from start to end. No array materialized — just a counter inside a closure.
function range(start, end) { let i = start; return { // Makes the object an ITERATOR next() { if (i < end) { return { value: i++, done: false }; } return { value: undefined, done: true }; }, // Makes it ITERABLE — for...of compatible [Symbol.iterator]() { return this; } }; } for (const n of range(1, 5)) { console.log(n); // 1, 2, 3, 4 }
[Symbol.iterator]() returns this: the protocol asks for an iterator object. Since our return value already implements next(), it qualifies as one. So we hand back the same object. This pattern — iterator that is also iterable — is what built-in iterators do.
Where this unlocks the language
const r = range(1, 5); // All of these JUST WORK now: [...r] // spread → [1,2,3,4] const [a, b] = range(10, 99); // destructuring → a=10, b=11 Array.from(range(1, 5)) // → [1,2,3,4] new Set(range(1, 5)) // → Set {1,2,3,4}
Iterators on autopilot
Writing { next() {...} } by hand gets tedious. Generators are a syntax — function* + yield — where the engine writes the iterator for you. Each yield is a "pause point". Each next() resumes until the next yield.
function* range(start, end) { for (let i = start; i < end; i++) { yield i; } } for (const n of range(1, 5)) console.log(n);
Two lines of meaningful logic instead of eight. Behind the scenes the engine creates an object with next() and [Symbol.iterator]() — identical to what we wrote by hand.
Infinite sequences become trivial
function* naturals() { let n = 1; while (true) yield n++; } const nums = naturals(); nums.next().value; // 1 nums.next().value; // 2 nums.next().value; // 3 ... forever, but only computed on demand
The lazy advantage: an array of 1 million numbers takes 1 million numbers worth of memory. A generator yielding 1 million numbers takes one number's worth — plus the closure. We'll exploit this in step 09.
forEach, map, filter — three patterns, one shape
These three are the workhorses. Look at them side by side and notice how nearly identical they are — just three slightly different return strategies.
forEach — execute, return nothing
Array.prototype.myForEach = function(callback, thisArg) { for (let i = 0; i < this.length; i++) { if (i in this) { // skip holes in sparse arrays callback.call(thisArg, this[i], i, this); } } // returns undefined — that's the contract }; [1, 2, 3].myForEach(n => console.log(n)); // 1, 2, 3
map — transform, return new array
Array.prototype.myMap = function(callback, thisArg) { const result = []; for (let i = 0; i < this.length; i++) { if (i in this) { result[i] = callback.call(thisArg, this[i], i, this); } } return result; }; [1, 2, 3].myMap(n => n * 2); // [2, 4, 6]
filter — test, return new array of matches
Array.prototype.myFilter = function(predicate, thisArg) { const result = []; for (let i = 0; i < this.length; i++) { if (i in this && predicate.call(thisArg, this[i], i, this)) { result.push(this[i]); } } return result; }; [1, 2, 3, 4].myFilter(n => n % 2 === 0); // [2, 4]
Callback signature
callback(value, index, array)
All three pass the same three args. Most code uses only the first.
Sparse array handling
The i in this check skips holes ([1,,3]). The spec requires this — map preserves the holes, doesn't visit them.
map vs forEach — when to use which
If you need the resulting array, use map. If you don't (logging, calling setState, DOM mutation), use forEach. Using map without using the return value is a code smell — you've allocated a throwaway array.
some, every, find — short-circuit the loop
Unlike forEach/map/filter, these stop as soon as they have their answer. That's their entire reason to exist over a filter + length check.
some — true if ANY element matches
Array.prototype.mySome = function(predicate, thisArg) { for (let i = 0; i < this.length; i++) { if (i in this && predicate.call(thisArg, this[i], i, this)) { return true; // short-circuit on first match } } return false; // empty array → false }; [1, 2, 3].mySome(n => n > 2); // true (stops at 3) [1, 2, 3].mySome(n => n > 9); // false
every — true if ALL elements match
Array.prototype.myEvery = function(predicate, thisArg) { for (let i = 0; i < this.length; i++) { if (i in this && !predicate.call(thisArg, this[i], i, this)) { return false; // short-circuit on first failure } } return true; // empty array → true (vacuous truth) }; [2, 4, 6].myEvery(n => n % 2 === 0); // true [2, 3, 6].myEvery(n => n % 2 === 0); // false (stops at 3)
The empty array trap
[].some(...); // false — nothing to match [].every(...); // true — "all zero elements pass" — vacuous truth
This is the #1 surprise. every on an empty array returns true. Mathematicians call this "vacuous truth" — there's no counterexample, so the claim holds.
find — return the first match, not its index
Array.prototype.myFind = function(predicate, thisArg) { for (let i = 0; i < this.length; i++) { if (predicate.call(thisArg, this[i], i, this)) { return this[i]; } } return undefined; }; [{id:1},{id:2}].myFind(u => u.id === 2); // { id: 2 }
findIndex — return the index of the first match, or -1
Array.prototype.myFindIndex = function(predicate, thisArg) { for (let i = 0; i < this.length; i++) { if (predicate.call(thisArg, this[i], i, this)) { return i; } } return -1; };
includes — value equality, with NaN handled
Array.prototype.myIncludes = function(target, fromIndex = 0) { const start = fromIndex < 0 ? Math.max(this.length + fromIndex, 0) : fromIndex; for (let i = start; i < this.length; i++) { // Object.is so NaN matches NaN — unlike === if (Object.is(this[i], target)) return true; if (this[i] === target) return true; } return false; }; [NaN].myIncludes(NaN); // true ← unlike indexOf which returns -1! [NaN].indexOf(NaN); // -1 ← uses === which NaN fails
indexOf vs includes — the NaN edge case
indexOf uses strict equality (===). Because NaN !== NaN, [NaN].indexOf(NaN) returns -1. includes was added in ES2016 specifically to fix this. Use includes when you just want a boolean.
reduce — the universal array method
Every other array method can be rewritten as a reduce. It's the most general — and the easiest to misuse. The signature has more nuance than it looks.
Array.prototype.myReduce = function(callback, initialValue) { let acc; let startIdx; if (arguments.length >= 2) { acc = initialValue; startIdx = 0; } else { if (this.length === 0) { throw new TypeError('Reduce of empty array with no initial value'); } acc = this[0]; // first element becomes initial startIdx = 1; // loop starts at second element } for (let i = startIdx; i < this.length; i++) { if (i in this) { acc = callback(acc, this[i], i, this); } } return acc; }; // Sum: [1, 2, 3, 4].myReduce((acc, n) => acc + n, 0); // 10 // Group by: users.myReduce((groups, u) => { (groups[u.role] = groups[u.role] || []).push(u); return groups; }, {});
Always pass an initialValue
Without one, an empty array throws. The first element becomes the accumulator instead of being processed by your callback — which causes bugs when array types and accumulator types differ.
[].reduce((a, b) => a + b); // TypeError! [].reduce((a, b) => a + b, 0); // 0 — safe ['a', 'b'].reduce((a, b) => a + b); // 'ab' — works by accident [{n:1}, {n:2}].reduce((a, b) => a.n + b.n); // NaN — bug
reduceRight — same, but iterates from the end
Array.prototype.myReduceRight = function(callback, initialValue) { let acc; let startIdx; if (arguments.length >= 2) { acc = initialValue; startIdx = this.length - 1; } else { if (this.length === 0) { throw new TypeError('Reduce of empty array with no initial value'); } acc = this[this.length - 1]; startIdx = this.length - 2; } for (let i = startIdx; i >= 0; i--) { if (i in this) { acc = callback(acc, this[i], i, this); } } return acc; };
Everything else built on reduce
// map via reduce const mapR = (arr, fn) => arr.reduce((a, x, i) => (a[i] = fn(x, i), a), []); // filter via reduce const filterR = (arr, p) => arr.reduce((a, x) => (p(x) && a.push(x), a), []); // some via reduce — but can't short-circuit. Use a real some for that. const someR = (arr, p) => arr.reduce((a, x) => a || p(x), false);
Reach for reduce when: you're collapsing many values into one. Sum, max, group-by, build an object from an array. Don't when a more specific method exists — filter + map is more readable than a reduce that pushes onto an array.
Working with nested arrays
Added in ES2019. Solve the recurring "I have an array of arrays" problem in one call.
flat — flatten by N levels
Array.prototype.myFlat = function(depth = 1) { const result = []; for (let i = 0; i < this.length; i++) { if (Array.isArray(this[i]) && depth > 0) { result.push(...this[i].myFlat(depth - 1)); // recurse } else { result.push(this[i]); } } return result; }; [1, [2, [3, [4]]]].myFlat(); // [1, 2, [3, [4]]] [1, [2, [3, [4]]]].myFlat(2); // [1, 2, 3, [4]] [1, [2, [3, [4]]]].myFlat(Infinity); // [1, 2, 3, 4]
flatMap — map, then flatten one level
Array.prototype.myFlatMap = function(callback, thisArg) { return this.myMap(callback, thisArg).myFlat(1); }; // Useful when one input produces many outputs: ['hello', 'world'].myFlatMap(w => w.split('')); // ['h','e','l','l','o','w','o','r','l','d'] // Or to filter + map in one pass — return [] to drop: nums.flatMap(n => n > 0 ? [n * 2] : []);
flatMap is also the "filter + map in one pass" pattern. Return [value] to keep, [] to drop. Avoids the intermediate array a separate .filter().map() creates.
Mutators vs non-mutators
Array methods fall into two camps with very different ergonomics. Confusing them is the source of about half of all React state bugs.
Non-mutating
RETURN A NEW ARRAY · ORIGINAL UNTOUCHED
map,filter,flat,flatMapslice,concattoSorted,toReversed,toSpliced,with(ES2023)
Mutating
MODIFY IN PLACE · RETURN VARIES
push,pop,shift,unshiftsplice,fill,copyWithinsort,reverse← classic gotcha
The .sort() trap in React
// Bug: sort mutates AND returns the same array reference const [items, setItems] = useState([3, 1, 2]); setItems(items.sort()); // React sees same reference → no re-render // Fix 1: copy first setItems([...items].sort()); // Fix 2: use ES2023 toSorted setItems(items.toSorted());
Why .sort() default behavior surprises everyone
[10, 1, 5, 20].sort(); // [1, 10, 20, 5] — WAT // Default comparator coerces to STRING. '20' < '5' lexicographically. [10, 1, 5, 20].sort((a, b) => a - b); // [1, 5, 10, 20] — pass a comparator. Always.
Generators replace huge intermediate arrays
Array methods are eager. [1..1e9].filter(...).map(...).slice(0, 10) allocates a billion-element array, then a filtered array, then a mapped array — just to take 10 items. Generators let you express the same pipeline lazily, producing only what's consumed.
const first10Evens = Array.from({length: 1e9}, (_, i) => i) .filter(n => n % 2 === 0) .map(n => n * 10) .slice(0, 10); // crash — out of memory before .slice even runs
function* naturals() { let n = 0; while (true) yield n++; } function* filter(iter, predicate) { for (const x of iter) if (predicate(x)) yield x; } function* map(iter, fn) { for (const x of iter) yield fn(x); } function* take(iter, n) { let i = 0; for (const x of iter) { if (i++ >= n) return; yield x; } } const pipeline = take( map(filter(naturals(), n => n % 2 === 0), n => n * 10), 10 ); [...pipeline]; // [0, 20, 40, 60, 80, 100, 120, 140, 160, 180]
Each yield only happens when the next stage asks for a value. Asking for 10 values from take causes 10 values to be pulled from map, which pulls from filter, which pulls from naturals. The infinite sequence becomes finite work, computed on demand.
Iterator.prototype methods (Iterator Helpers, Stage 4) work. Lazy by default. Worth getting comfortable with — they're shipping in browsers right now.
The interview answer
"Array methods are eager loops over this that pass (value, index, array) to a callback. map, filter, forEach walk the whole array; some, every, find short-circuit on first result; reduce collapses everything into a single accumulator. Underneath array methods sits the iterator protocol — Symbol.iterator and next() — which is what makes for...of, spread, destructuring, and Array.from all work on any object that opts in. Generators are syntactic sugar for writing iterators with yield, and they enable lazy pipelines that don't allocate intermediate arrays."
Ten things that fall out
- Iterable = object with
[Symbol.iterator](). Iterator = object withnext(). Two interfaces, three properties. - Callback signature is always
(value, index, array)for the standard methods. - some/every short-circuit.
filter(...).length > 0doesn't. Use the right tool. everyon an empty array is true.someis false. Vacuous truth.includeshandles NaN.indexOfdoesn't.- Always pass an initial value to
reduce. Empty arrays throw without one. - Sort by default is lexicographic. Always pass a comparator for numbers.
- Sort and reverse mutate AND return the same array. Use spread or ES2023
toSorted/toReversed. - Generators write iterators for you.
function*+yieldis 90% less code than hand-rolling. - Lazy iteration beats eager arrays when only a portion of the result is consumed.
Before you leave — how confident are you with this?
Your honest rating shapes when you'll see this again. No grades, no shame.
Comments
Loading comments…