Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 06 / 18 30 MIN

Collections and iteration

Destructuring, Spread, Iterators and Generators

Pull values out of arrays and objects, copy them without sharing nested state, make your own objects iterable, and use generators to defer work.

A function returns a user object with twelve fields and the caller needs two. The obvious version is four lines of const email = user.email, with the risk that one spelling is wrong. The other version is one line with braces on the left of the equals sign.

That syntax is destructuring, and it shares its rules with the rest and spread operators that copy and merge arrays and objects without mutation. The same rules explain why { ...config } is not a deep copy, which is a mistake that survives code review because the clone looks correct where it is made.

The second half of the lesson is the machinery underneath for...of. Arrays and strings work with it, your own objects do not until you write one method, and generator functions are the shorthand for writing that method in a form that pauses. That pause is why generators exist.

Destructuring reads by position for arrays and by name for objects

Array patterns read by index. Object patterns read by property name, and the braces on the left are doing the same job as the braces in an object literal:

const point = [3, 7, 11];
const [x, y] = point;
console.log(x, y);            // 3 7

const [first, , third] = point;
console.log(first, third);    // 3 11

const response = { status: 200, body: { id: 7 } };
const { status, body: { id } } = response;
console.log(status, id);      // 200 7

The empty slot between commas skips an element, and the pattern nests when the value does. An object pattern reads by name, so const { body } = response gives the whole nested object while const { body: { id } } reaches inside it. The syntax is a literal read from the inside out, which is why { status: code } means "read the status property and bind it to a new variable called code". The colon is a rename, not a type annotation. That trips up anyone arriving from TypeScript, where the same colon in a function parameter means a type.

Defaults fire only for undefined, which is narrower than most people assume:

const { retries = 3 } = { retries: undefined };
console.log(retries);         // 3

const { limit = 10 } = { limit: null };
console.log(limit);           // null

const { label = 'none' } = { label: '' };
console.log(label);           // ''

null and '' survive. A default is a fallback for a value that was never supplied, not a validator for a value you dislike, and if you need the second thing, write it as a check. The default can also reference an earlier binding in the same pattern, which is how a parameter that defaults to another parameter works:

function area({ width, height = width } = {}) {
  return width * height;
}

console.log(area({ width: 4 }));            // 16
console.log(area({ width: 4, height: 5 })); // 20
console.log(area());                        // NaN

The trailing = {} makes the whole argument optional, so area() does not throw for a missing argument. It returns NaN instead, because the empty object has no width and undefined * undefined is NaN. A default on the outer argument turns a crash into a wrong number, and only one of those shows up in your logs.

Swapping two variables is the one place the array form pays for itself immediately, because it works without a temporary:

let width = 10;
let height = 20;
[width, height] = [height, width];
console.log(width, height);   // 20 10

The parentheses are not needed here and are needed when the statement begins with a brace, because the parser reads a leading { as a block.

Rest collects what is left; spread expands what you have

Rest appears on the left of an assignment and gathers remaining values into a new array or object. Spread appears on the right and expands an iterable into positions. They share a token because they are the same operation viewed from opposite ends:

function total(...values) {
  return values.reduce((sum, value) => sum + value, 0);
}
console.log(total(1, 2, 3));            // 6
console.log(Math.max(...[4, 9, 2]));    // 9

const user = { name: 'Ana', email: 'ana@example.com', role: 'admin' };
const { name: who, ...metadata } = user;
console.log(who, metadata);
// Ana { email: 'ana@example.com', role: 'admin' }

total(...values) accepts any number of arguments and gets them as a real array, which is why reduce works on it. The object rest pattern splits one object into a named field and a new object holding the rest, which is the pattern behind functions that accept an options bag and forward the unknown keys:

function log({ level = 'info', message, ...extra }) {
  return { level, message, extra };
}
console.log(log({ message: 'started', requestId: 'r1' }));
// { level: 'info', message: 'started', extra: { requestId: 'r1' } }

Spread also works on anything iterable, which is a larger set than arrays. Strings spread into characters, sets into their members, and maps into pairs:

console.log([...'abc']);                       // [ 'a', 'b', 'c' ]
console.log([...new Set([1, 2, 2])]);          // [ 1, 2 ]
console.log([...new Map([['a', 1]])]);         // [ [ 'a', 1 ] ]
console.log([...[1, 2], ...[3, 4], 5]);        // [ 1, 2, 3, 4, 5 ]

A plain object is not iterable, so [...{ a: 1 }] throws TypeError: {(intermediate value)} is not iterable. Object spread is separate syntax that copies own enumerable properties, and it is why { ...config, debug: true } makes a new object with one field replaced. Order matters there: a later key overwrites an earlier one, so put the overrides on the right.

The copy is shallow, so nested state is still shared

Spread copies one level. The values inside the copy are the same values, and for objects and arrays that means the same references:

const rows = [
  { id: 1, tags: ['a'] },
  { id: 2, tags: ['b'] },
];

const copy = [...rows];
console.log(copy === rows);       // false
console.log(copy[0] === rows[0]); // true

copy[0].tags.push('x');
console.log(rows[0].tags);        // [ 'a', 'x' ]

Pushing into the copy changed the original, and nothing warned you. The same thing happens with an object:

const config = { host: 'localhost', options: { retries: 1 } };
const clone = { ...config };

clone.options.retries = 99;
console.log(config.options.retries);   // 99

const deep = structuredClone(config);
deep.options.retries = 5;
console.log(config.options.retries, deep.options.retries);   // 99 5

This is why state updates in a component are written as { ...state, options: { ...state.options, retries: 5 } }. Each level that changed gets its own copy, so the old object still points at the old nested object and whatever compared them can tell that something changed. Copying only the top level is the bug that produces a component which re-renders nothing.

structuredClone deep-copies in one call and handles Date, Map, Set and cycles, which JSON.parse(JSON.stringify(value)) does not: the JSON round trip turns a Date into a string and drops undefined and functions. Reach for structuredClone when the data is plain data, and for explicit nested spreads when it is not.

for...of calls a method, and you can write that method yourself

Arrays, strings, Map, Set and generators all work with for...of because they have a method named Symbol.iterator. That method returns an object with a next() method, and each call to next() returns { value, done }. Nothing more is required:

const range = {
  from: 1,
  to: 3,
  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;
    return {
      next() {
        if (current <= last) return { value: current++, done: false };
        return { value: undefined, done: true };
      },
    };
  },
};

console.log([...range]);          // [ 1, 2, 3 ]
const [head, ...tail] = range;
console.log(head, tail);          // 1 [ 2, 3 ]
console.log(Math.max(...range));  // 3

Every consumer of the protocol goes through the same method, so one implementation gives you spread, array destructuring, rest, for...of, and the Array.from family. The iterator object itself is iterable and returns this from its own Symbol.iterator, which is what lets you pause a loop and resume it later:

const iterator = range[Symbol.iterator]();
console.log(iterator.next());    // { value: 1, done: false }
console.log(iterator.next());    // { value: 2, done: false }
console.log(iterator.next());    // { value: 3, done: false }
console.log(iterator.next());    // { value: undefined, done: true }

The value that comes with done: true is ignored by for...of, which calls [Symbol.iterator]() once and then next() until done. The object you wrote is not an array and does not have map or filter, which is the honest trade: you get the protocol and give up the array methods unless you convert with [...range] or Array.from(range).

Generators are the shorthand for writing an iterator

A generator function is declared with an asterisk and uses yield where you would otherwise build an object and return it. The function does not run when you call it. It runs up to the first yield, returns that value, and stops until the next call:

function* pick(rows, limit) {
  let sent = 0;
  for (const row of rows) {
    if (sent === limit) return;
    sent += 1;
    yield row;
  }
}

const rows = ['r1', 'r2', 'r3', 'r4'];
const firstTwo = pick(rows, 2);

console.log(firstTwo.next());   // { value: 'r1', done: false }
console.log(firstTwo.next());   // { value: 'r2', done: false }
console.log(firstTwo.next());   // { value: undefined, done: true }
console.log([...pick(rows, 2)]);  // [ 'r1', 'r2' ]

The loop body is ordinary JavaScript. A return ends the sequence and sets done: true whether or not anything was returned, and the generator is exhausted at that point. A generator object is iterable, so spread, for...of and destructuring all work on it, and it is also one-shot: a second [...] over the same exhausted generator gives an empty array. Call the function again for a fresh sequence.

Values also travel back in. next(value) makes the suspended yield expression evaluate to value:

function* logger() {
  const name = yield 'who?';
  yield `hello ${name}`;
}

const gen = logger();
console.log(gen.next());          // { value: 'who?', done: false }
console.log(gen.next('Ana'));     // { value: 'hello Ana', done: false }

That two-way channel is what makes generators usable for coroutines. It is also the mechanism underneath async and await, which are built from generators and promises. The last module of this course comes back to that.

Lazy evaluation is why generators exist

An array method runs to completion before you can look at the first result. A generator produces one value per request, so it can represent a sequence that has no end and it can stop the moment the consumer has what it needs:

function* naturals() {
  let n = 0;
  while (true) {
    console.log('producing', n);
    yield n;
    n += 1;
  }
}

function* take(source, count) {
  let taken = 0;
  for (const value of source) {
    if (taken === count) return;
    taken += 1;
    yield value;
  }
}

console.log([...take(naturals(), 3)]);
producing 0
producing 1
producing 2
producing 3
[ 0, 1, 2 ]

Four values produced, three delivered. The generator counted to three, then pulled one more value before its loop could exit, which is the one wasted step in this design and a detail worth seeing at least once. The important line is the missing one: nothing allocated an array of integers, and nothing would have if the count had been a million.

This is the whole reason generators exist. A pipeline of array methods, map then filter then slice, allocates an intermediate array per stage and visits every element at every stage. The same pipeline written as generators reads one element at a time, pushes it through each stage and yields it, so intermediate storage is one value per stage rather than one array per stage. For a thousand rows the difference is noise. For a streaming file, a paginated API or an unbounded sequence, it is the difference between working and exhausting memory.

Two limits are worth stating. Generators are not free; each one carries the suspended call frame, and a pipeline of five generators has five frames live at once, which makes a chain slower than a tight loop for small data. And a generator is the wrong tool when the consumer needs random access, a length, or two passes. Convert to an array where you need those properties, not before. TypeScript types the return as Generator<number, void, void>, and a consumer that only needs to iterate can accept Iterable<number> to stay compatible with arrays.

Lab 30 minutes

Build a pagedEvents generator that takes an array of { id, kind, at } events and a page size, and yields pages of that size as arrays, with a final partial page when the events do not divide evenly. Then build a second generator, onlyKind(pages, kind), that takes a page stream and yields only the events of one kind, still as pages.

Use destructuring to pull the first event out of each page in a for...of loop, and use spread to build the page arrays without mutating the input.

Then answer two questions. What does your pagedEvents generator do with an empty input array, and is that the right answer? And if a caller wants to iterate the same page stream twice, what has to change?

Show the solution and the reasoning
function* pagedEvents(events, size) {
  for (let index = 0; index < events.length; index += size) {
    yield events.slice(index, index + size);
  }
}

function* onlyKind(pages, kind) {
  for (const page of pages) {
    const matching = page.filter((event) => event.kind === kind);
    if (matching.length > 0) yield matching;
  }
}

const events = [
  { id: 1, kind: 'click', at: '09:00' },
  { id: 2, kind: 'view', at: '09:01' },
  { id: 3, kind: 'click', at: '09:02' },
  { id: 4, kind: 'view', at: '09:03' },
  { id: 5, kind: 'click', at: '09:04' },
];

for (const page of pagedEvents(events, 2)) {
  const [first, ...others] = page;
  console.log('page', first.id, 'has', others.length + 1, 'events');
}

console.log('--- clicks only ---');
for (const page of onlyKind(pagedEvents(events, 2), 'click')) {
  console.log(page.map((event) => event.id));
}

console.log('empty input:', [...pagedEvents([], 2)]);
console.log('empty input pages:', JSON.stringify([...pagedEvents([], 2)]));
page 1 has 2 events
page 3 has 2 events
page 5 has 1 events
--- clicks only ---
[ 1 ]
[ 3 ]
[ 5 ]
empty input: []
empty input pages: []

pagedEvents is a generator around a for loop rather than one around a yield per event, because the unit being produced is a page. events.slice(index, index + size) reads a range without touching the input, so the caller's array keeps its original order and length, and the final partial page comes out naturally from slice clipping at the end.

onlyKind filters inside a page and yields the page only when something matched. Because the paging happens first, filtering shrinks the pages: the two clicks that shared a page with a view come out as pages of one. That is the correct reading of "filter a page stream", and the wrong function if you wanted full pages of clicks, which needs the filter applied before the paging. Yielding an empty array for a page with no matches would instead push the emptiness check into every consumer.

An empty input produces no pages at all. There is nothing to page, and a single empty page would make every caller handle a case that carries no information. The cost is that a caller rendering a header for the stream has to handle "no pages" itself.

The destructuring in the loop is doing a little more than tidying. const [first, ...others] = page on an empty page gives undefined and [], no error, and the count printed would be 1 for a page with nothing in it. That is the kind of wrong number that reads as correct in output.

Iterating twice is the limit of this design. A generator is one-shot, so a second for...of over the same pagedEvents(events, 2) object produces nothing, silently. A caller who needs two passes can call the generator function twice to get two independent iterators, or materialise once with [...pagedEvents(events, 2)] and iterate the array, which trades lazy behaviour for repeatability. That choice belongs to whoever knows the size of the input.

Progress

Mark this lesson done and it stays marked in this browser.