Field Guide · Vol. 9
Loops → Lazy Sequences

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

  1. The iterator protocol — what makes something "iterable"
  2. Building your own iterator from scratch
  3. Generators — iterators with autopilot
  4. Array methods, step by step: forEach, map, filter
  5. Predicate methods: some, every, find, findIndex, includes
  6. Reduction: reduce, reduceRight
  7. Search & access: indexOf, flat, flatMap
  8. Mutators vs non-mutators — the danger list
  9. Lazy iteration — why generators beat arrays for big data
01 · The Foundation

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.

Iterable [1, 2, 3] [Symbol.iterator]() returns → Iterator { next() { ... } } stateful — tracks position next() → Result { value: 1, done: false } SUBSEQUENT CALLS { value: 2, done: false } { value: 3, done: false } { value: undefined, done: true } → loop stops

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.

02 · Hand-Rolled Iterator

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
}
Why [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}
03 · Generators

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.

✓ Same range, as a generator
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.

04 · The Basics

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.

05 · Predicates

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.

06 · The Power Tool

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.

07 · Flat & FlatMap

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.

08 · The Danger Zone

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, flatMap
  • slice, concat
  • toSorted, toReversed, toSpliced, with (ES2023)

Mutating

MODIFY IN PLACE · RETURN VARIES

  • push, pop, shift, unshift
  • splice, fill, copyWithin
  • sort, 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.
09 · The Lazy Endgame

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.

✗ Eager — 3 billion-element arrays in memory
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
✓ Lazy — produces only what's consumed
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.

This is exactly how Rust's iterators, Java streams, and proposed JavaScript 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

  1. Iterable = object with [Symbol.iterator](). Iterator = object with next(). Two interfaces, three properties.
  2. Callback signature is always (value, index, array) for the standard methods.
  3. some/every short-circuit. filter(...).length > 0 doesn't. Use the right tool.
  4. every on an empty array is true. some is false. Vacuous truth.
  5. includes handles NaN. indexOf doesn't.
  6. Always pass an initial value to reduce. Empty arrays throw without one.
  7. Sort by default is lexicographic. Always pass a comparator for numbers.
  8. Sort and reverse mutate AND return the same array. Use spread or ES2023 toSorted/toReversed.
  9. Generators write iterators for you. function* + yield is 90% less code than hand-rolling.
  10. Lazy iteration beats eager arrays when only a portion of the result is consumed.
JavaScript iterators & array methods · Frontend Field Guides

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…

Keep reading