IntermediateFunctional techniques and collection design~45 min

Functional Techniques and Collection Design

Composing small functions into pipelines, treating immutability as a deliberate design choice, choosing the right collection type, and writing a custom iterable.

By the end of this lesson, you can

  • Compose small, focused functions into a pipeline instead of one large function
  • Explain why immutable updates matter for predictability, and where structuredClone fits and where it doesn't
  • Choose among Map, Set, WeakMap, and a plain object based on their actual trade-offs
  • Write a custom iterable using the well-known Symbol.iterator protocol

Why it matters

Arrays and Objects introduced map/filter/reduce as individual tools. Used together β€” composed into pipelines, paired with immutable updates, backed by the right collection type β€” they become a coherent style for transforming data that’s easier to test and reason about than an equivalent imperative loop.

Higher-order functions and composition

A higher-order function takes a function as an argument, returns one, or both β€” map and filter already qualify. Composition chains small, single-purpose functions into a pipeline, each one handling one transformation:

JavaScriptComposing small functions instead of one large one
const trim = (s) => s.trim();
const toLowerCase = (s) => s.toLowerCase();
const removeExtraSpaces = (s) => s.replace(/\s+/g, ' ');

function pipe(...fns) {
  return (input) => fns.reduce((value, fn) => fn(value), input);
}

const normalize = pipe(trim, toLowerCase, removeExtraSpaces);
console.log(normalize('  Hello   World  ')); // "hello world"

Each function in the pipeline is small, independently testable, and reusable elsewhere β€” the reduce-based pipe helper is itself a higher-order function, taking functions in and returning a new one.

Immutability as a design choice

Recall from Arrays and Objects that spread produces a shallow copy. Choosing to always produce a new value instead of mutating β€” even where mutation would technically work β€” is a deliberate design decision, not just a syntax preference:

// Mutating β€” works, but anything else holding a reference to cart sees the change too
function addItemMutating(cart, item) {
  cart.items.push(item);
  return cart;
}

// Immutable β€” always produces a new structure; the original is untouched
function addItemImmutable(cart, item) {
  return { ...cart, items: [...cart.items, item] };
}

The immutable version is safe to call without worrying about who else might be holding a reference to the original β€” directly relevant to predictable state in a UI, and to the pure-function discipline from Functions and Scope.

Choosing a collection type

Map, Set, WeakMap, and a plain object β€” real trade-offs, not interchangeable defaults
TypeKeysUse when
ObjectStrings/Symbols onlyA fixed, known shape β€” a record, not a general lookup table
MapAny value, including objectsA general key/value lookup, especially with non-string keys
Set(values, no keys)A collection of unique values, with fast membership checks
WeakMapObjects only, weakly heldAttaching private data to an object without preventing garbage collection

Map preserves insertion order and reports its size directly (map.size, versus Object.keys(obj).length); WeakMap/WeakSet hold their keys weakly β€” a key that becomes otherwise unreachable can still be garbage collected, even while referenced by the WeakMap, which is exactly why they can’t be iterated or have their size checked (doing so would need to expose information about garbage collection timing).

Iterables and generators

An object is iterable if it implements the well-known Symbol.iterator method β€” this is what lets for...of and spread work on it, beyond the built-in arrays/strings/Map/Set:

JavaScriptA custom iterable using a generator
function range(start, end) {
  return {
    [Symbol.iterator]: function* () {
      for (let i = start; i < end; i++) {
        yield i;
      }
    },
  };
}

for (const n of range(1, 4)) {
  console.log(n); // 1, 2, 3
}
console.log([...range(1, 4)]); // [1, 2, 3] β€” spread works too, since it's iterable

A generator function (function*) is the easiest way to implement the iterator protocol by hand: calling it doesn’t run its body immediately β€” it returns an iterator, and each yield pauses execution, handing back one value at a time, resuming exactly where it left off on the next request.

Check your understanding

What does this log? function* countUp() { yield 1; yield 2; yield 3; } const it = countUp(); console.log(it.next().value); console.log(it.next().value);
Or reveal the answer without checking

Answer:1 then 2
Each call to .next() resumes the generator from where it last paused. The first call runs to the first yield (1); the second call resumes from there and runs to the next yield (2).

You need to attach a cache entry to a DOM element without preventing that element from being garbage collected once removed from the page. Which collection fits?
Or reveal the answer without checking

Answer:A WeakMap, keyed by the element
A WeakMap holds its keys weakly β€” the element can still be garbage collected once nothing else references it, automatically cleaning up the associated cache entry. A regular Map would keep the element (and everything it references) alive for as long as the Map itself exists.

Practice: warm-up

Predict the result, then check:

const double = (n) => n * 2;
const addOne = (n) => n + 1;

function pipe(...fns) {
  return (input) => fns.reduce((value, fn) => fn(value), input);
}

const transform = pipe(double, addOne);
console.log(transform(5));

Stuck? Reveal one hint at a time.

  1. Hint 1

    pipe applies its functions left to right β€” double first, then addOne β€” not right to left.

  2. Hint 2

    5 doubled is 10; 10 plus 1 is 11.

Reveal the result

Try the problem yourself before reading this. There is often more than one reasonable approach β€” treat this as one worked example, not the only correct answer.

11

Practice: apply it

Write a generator function take(iterable, count) that yields only the first count values from any iterable, then stops β€” even if the source iterable is infinite.

Stuck? Reveal one hint at a time.

  1. Hint 1

    for...of works directly on any iterable, generators included β€” you can iterate the input inside your generator.

  2. Hint 2

    Track how many values have been yielded so far, and return (stopping the generator) once the count is reached.

Reveal a reference solution

Try the problem yourself before reading this. There is often more than one reasonable approach β€” treat this as one worked example, not the only correct answer.

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

function* naturalNumbers() {
  let n = 1;
  while (true) yield n++;
}

console.log([...take(naturalNumbers(), 5)]); // [1, 2, 3, 4, 5]

Summary

  • A higher-order function takes or returns a function; composing several small ones into a pipeline (pipe) keeps each transformation independently testable and reusable.
  • Choosing immutable updates over mutation, even where mutation would work, avoids surprising anything else holding a reference to the original value β€” structuredClone deep-copies plain data, but has real limits (no functions, no DOM nodes).
  • Map/Set fit general or non-string-keyed lookups better than a plain object; WeakMap/WeakSet hold their keys weakly, letting an object be garbage collected even while referenced by them β€” useful for attaching data to an object without extending its lifetime.
  • Symbol.iterator is what makes an object work with for...of and spread; a generator function (function*) is the easiest way to implement it, pausing at each yield and resuming only when the next value is actually requested.