DEV Community

JKC
JKC

Posted on

Stop Spreading Iterables Into Arrays to Join Them. Iterator.concat() Is Here

TL;DR: Iterator.concat(a, b, c) walks any iterables one after another and returns a lazy iterator. No temporary array, no hand-written generator, and it has been Baseline Newly available since March 2026.

The array you build just to throw away

You have an array, a Set, and a generator. You want to loop over all of them in order. So you write the line everyone writes:

for (const item of [...pinned, ...recentSet, ...loadMore()]) {
  render(item);
}
Enter fullscreen mode Exit fullscreen mode

It works. It also copies every item into a brand new array, runs the generator to the very end, and then hands the array to the garbage collector a moment later. If you only needed the first few items, you paid for all of them.

The careful version is a generator with yield*:

function* concat(...iterables) {
  for (const iterable of iterables) {
    yield* iterable;
  }
}
Enter fullscreen mode Exit fullscreen mode

Correct, lazy, and copy-pasted into a utils folder near you.

What it is

Iterator.concat() is that generator, built into the language. It takes any number of iterables and returns an iterator that yields from each one in turn:

const array = [1, 2, 3];
const set = new Set([4, 5]);
function* gen() {
  yield 6;
  yield 7;
}

console.log([...Iterator.concat(array, set, gen())]);
// [1, 2, 3, 4, 5, 6, 7]
Enter fullscreen mode Exit fullscreen mode

The result is a real iterator, so the iterator helpers work on it directly:

const evensTimesTen = Iterator.concat([1, 2, 3], new Set([4, 5, 6]))
  .filter((n) => n % 2 === 0)
  .map((n) => n * 10)
  .toArray();

console.log(evensTimesTen); // [20, 40, 60]
Enter fullscreen mode Exit fullscreen mode

Maps are iterables of [key, value] pairs, which makes "defaults, then overrides" a one-liner:

const defaults = new Map([["theme", "light"], ["lang", "en"]]);
const user = new Map([["theme", "dark"]]);

const settings = new Map(Iterator.concat(defaults, user));
console.log(Object.fromEntries(settings)); // { theme: "dark", lang: "en" }
Enter fullscreen mode Exit fullscreen mode

Later keys win, because that is how the Map constructor already behaves.

The realistic one: take only what you need

Here is where laziness pays. Two sources of 1000 rows each, and a UI that shows the first three:

let pulled = 0;

function* page(start) {
  for (let i = start; i < start + 1000; i++) {
    pulled++;
    yield i;
  }
}

const spread = [...page(0), ...page(1000)].slice(0, 3);
console.log(spread, pulled); // [0, 1, 2] 2000

pulled = 0;

const lazy = Iterator.concat(page(0), page(1000)).take(3).toArray();
console.log(lazy, pulled); // [0, 1, 2] 3
Enter fullscreen mode Exit fullscreen mode

Same answer. The spread version produced 2000 rows to keep 3. The concat version produced 3. Replace "row" with "parsed log line" or "decoded image" and that gap stops being academic.

It is also lazy about opening each source. Logging every [Symbol.iterator]() call on two iterables gave this order:

created
open a
next 1
next 2
open b
next 3
Enter fullscreen mode Exit fullscreen mode

Nothing is opened when you call concat, and b is not touched until a runs out. That also means infinite sources are fine, as long as you stop early:

function* naturals() {
  let i = 1;
  while (true) yield i++;
}

console.log(Iterator.concat(["start"], naturals()).take(5).toArray());
// ["start", 1, 2, 3, 4]
Enter fullscreen mode Exit fullscreen mode

Can I use it?

Where Status
Baseline Newly available since March 2026
Chrome and Edge 146
Firefox 147
Safari (macOS and iOS) 26.4
Node.js 24 Not yet: typeof Iterator.concat is "undefined" in v24.11.0
Spec TC39 Iterator Sequencing proposal, Stage 4

Everything in this post ran in Chrome 156. If you need it in Node today, keep the five-line generator from above as a fallback.

Gotchas

  1. Strings are rejected, even though they are iterable. Iterator.concat([1], "ab") throws TypeError: Iterator.concat called on non-object right away, at the call, not later during iteration. null throws the same error. Wrap a string first: Iterator.from("bc") or [..."bc"] both worked.

  2. Bare iterators need Iterator.from(). An object with only a next() method is not iterable, and passing one threw TypeError: #<Object> is not iterable. Built-in iterators like [1, 2, 3].values() are fine, because they are iterable too.

  3. The result is single use. Spreading the same concat result twice gave [1, 2, 3] the first time and [] the second. It is an iterator, not an array, so call Iterator.concat again if you need a second pass.

  4. Breaking early closes what it opened. With two generators that log in finally, a for...of that broke on the first item of the second one logged closed a when a ran out and closed b on the break. Your cleanup code runs, which is exactly what you want.

Wrap-up

Iterator.concat() is the yield* helper you kept rewriting, now with iterator helpers attached. Next time you type [...a, ...b] only to loop over it once, try Iterator.concat(a, b) instead.

Sources

This article was written with the help of AI and checked against the sources above.

Top comments (1)

Collapse
 
mercerk17 profile image
Kai Mercer •

Useful write-up, and the laziness test is the convincing part. One more difference from the spread version that I checked in Chrome 155: Iterator.concat reads each source when iteration reaches it, not when you call it. I made it from an array [1, 2], pushed 3 onto that array afterwards, and spreading the result gave [1, 2, 3]. [...a, ...b] would have copied [1, 2] at that moment. Handy if a source can grow, a surprise if you expected a snapshot.

Also from MDN's page: the list of iterables has to be finite and fairly short (engines limit function arguments), so for a big or unbounded list of sources it points to flatMap instead of spreading them into concat. Only tested in Chrome, not other browsers.