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:
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
| Type | Keys | Use when |
|---|---|---|
| Object | Strings/Symbols only | A fixed, known shape β a record, not a general lookup table |
| Map | Any value, including objects | A general key/value lookup, especially with non-string keys |
| Set | (values, no keys) | A collection of unique values, with fast membership checks |
| WeakMap | Objects only, weakly held | Attaching 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:
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 iterableA 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
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).
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.
Hint 1
pipe applies its functions left to right β double first, then addOne β not right to left.
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.
11Practice: 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.
Hint 1
for...of works directly on any iterable, generators included β you can iterate the input inside your generator.
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 β
structuredClonedeep-copies plain data, but has real limits (no functions, no DOM nodes). Map/Setfit general or non-string-keyed lookups better than a plain object;WeakMap/WeakSethold 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.iteratoris what makes an object work withfor...ofand spread; a generator function (function*) is the easiest way to implement it, pausing at eachyieldand resuming only when the next value is actually requested.