Learn JS Series (#30) - Mini Project: A Small Functional Utility Library

Words
2881
Reading
13 min
Listen
Play
13h

Learn JS Series (#30) - Mini Project: A Small Functional Utility Library

js-banner.png

What will I learn

  • You will build a small, reusable functional utility library from scratch, tying together the whole of Phase 2;
  • how to implement reduce, map, and filter yourself, so they hold no mystery whatsoever;
  • how to build pipe, compose, curry, and memoize as reusable, composable tools;
  • how first-class functions, closures, and higher-order functions combine into a real, usable toolkit;
  • how to organize a tiny library and prove that every piece works with dead-simple tests.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Node.js (20+) distribution, or just a modern browser console;
  • Episodes 1-29 read: this project leans on everything from Phase 2.

Difficulty

  • Intermediate

Curriculum (of the Learn JS Series):

Learn JS Series (#30) - Mini Project: A Small Functional Utility Library

Solutions to Episode 29 Exercises

Exercise 1 - a memoized factorial:

function makeFactorial() {
  const cache = new Map([[0, 1], [1, 1]]);
  return function fac(n) {
    if (cache.has(n)) return cache.get(n);
    const result = n * fac(n - 1); // reuses cached smaller factorials
    cache.set(n, result);
    return result;
  };
}
const factorial = makeFactorial();
console.log(factorial(5)); // 120 (computes 2..5)
console.log(factorial(6)); // 720 (reuses cached 5!, one more multiply)

The insight: because fac(6) calls fac(5), which is already cached, only one new multiplication happens instead of the whole product from scratch.

Exercise 2 - memoizing an expensive square:

function memoize(fn) {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}
const slowSquare = (n) => { for (let i = 0; i < 1e7; i++) {} return n * n; };
const fast = memoize(slowSquare);
console.log(fast(9)); // slow first call - does the loop
console.log(fast(9)); // instant second call - straight from cache

The insight: the first call computes and caches; the second returns the stored result with no loop at all.

Exercise 3 - why some functions must not be memoized:

// BUG: memoizing this caches a single random value forever
const rand = () => Math.random();
// memoize(rand)() would return the SAME number every time - wrong!
// GOOD candidate: a pure, expensive function like an isPrime check on repeated inputs
const isPrime = (n) => { for (let i = 2; i * i <= n; i++) if (n % i === 0) return false; return n > 1; };

The insight: Math.random() and the clock give different outputs for the same input, so caching them is silently incorrect; a pure, expensive function called with repeated inputs (like isPrime) is the ideal candidate.

Right, so here we are. Twenty-nine episodes of Phase 2 behind us, and today we cash in the whole lot. This is the fun part, the part I always look forward to when teaching a phase: no new theory, no new syntax to memorise, just the deeply satisfying business of watching a pile of small ideas snap together into something you would genuinely reach for in real code. We are going to build our own small functional utility library, the kind of thing libraries like Lodash and Ramda give you off the shelf, except ours will be tiny and understood top to bottom, with no magic hiding anywhere. Every single function will be pure (episode 28), built out of closures (episode 17) and higher-order functions (episode 18). Everything Phase 2 taught, converging on one little toolkit. Let's build it.

What we are building

The plan is a single object, call it F, that collects the functional tools we have been sharpening all phase: our own versions of reduce, map, and filter, plus pipe, compose, curry, and memoize. Seven tools in total. Building them ourselves is not busywork -- it cements exactly how each one works, and (this is the part I love) the result is a genuinely usable little library that you could drop into a real project tomorrow.

Why an object rather than seven loose functions? Two reasons, both practical. First, it gives every tool a clear home and a readable call-site: F.pipe(...) reads better than a bare pipe floating in your global scope, and it makes clear which functions belong together. Second, it mirrors how the real libraries package themselves -- Lodash is essentially one big _ object, Ramda one big R object. We are building a miniature of the same idea. Having said that, in a real codebase you would put F in its own module file and export it, which is a Phase 8 topic; for now, one object in one file keeps everything in front of us.

A quick word on the design principle that runs through all seven functions: every one is pure and every one that works on arrays returns a new result without mutating its input. That is not me being fussy -- it is what makes the whole toolkit composable and testable. Pure functions snap together like Lego (episode 27), and impure ones fight you at every join. So purity is the through-line here, not an afterthought.

Implementing reduce, map, and filter

You have been calling these array methods since episode 11, and by now they feel like part of the furniture. Today we implement them ourselves, so they hold precisely zero mystery. Each takes an array and a function, and returns a new result without touching the input. We build reduce first, deliberately, because -- and this is one of those facts that quietly reshapes how you think about arrays -- the other two can be expressed entirely in terms of it.

const F = {};

F.reduce = function (array, reducer, initial) {
  let accumulator = initial;
  for (const item of array) {
    accumulator = reducer(accumulator, item); // fold each item into the accumulator
  }
  return accumulator;
};

console.log(F.reduce([1, 2, 3, 4], (sum, n) => sum + n, 0)); // 10

reduce walks the array left to right, threading an accumulator through the reducer function on each step, exactly as we described back in episode 11. Start with initial, then for each item compute a new accumulator from the old accumulator and the item, and finally return whatever accumulator you end up with. That is the entire mechanism. The reason reduce is so powerful is that "combine a running value with each element" is an astonishingly general shape -- summing is just one instance of it. Watch what falls out.

F.map = function (array, fn) {
  return F.reduce(array, (acc, item) => [...acc, fn(item)], []);
};

F.filter = function (array, predicate) {
  return F.reduce(array, (acc, item) => (predicate(item) ? [...acc, item] : acc), []);
};

console.log(F.map([1, 2, 3], (n) => n * 10));       // [10, 20, 30]
console.log(F.filter([1, 2, 3, 4], (n) => n % 2));  // [1, 3]

Look closely, because this is genuinely one of the little joys of functional programming. map is just a reduce that starts with an empty array and, for each item, appends the transformed item. filter is just a reduce that starts with an empty array and appends the item only when the predicate passes, otherwise leaves the accumulator untouched. Neither one mutates the source array -- they build a fresh array each time, staying pure (episode 28). Seeing map and filter fall straight out of reduce is one of those moments where the pieces click: reduce really is the general tool, and the others are specialised conveniences.

Now, a word of honesty, because I am not in the habit of selling you a technique without its price tag. Building map and filter on top of reduce with [...acc, item] is beautiful for teaching -- it shows the relationship crystal-clearly -- but it is not how you would write them for performance. Spreading [...acc, item] on every step copies the whole accumulator array each time, which turns an O(n) job into an O(n squared) one. For a library you truly cared about speed in, you would push into a single array in place inside the reducer. But for learning the shape of these operations, and for the modest arrays most code actually deals with, the elegant version is exactly right. Clarity first, optimise later, and only when a profiler tells you to -- the same measure-first refrain from episode 29.

Adding pipe and compose

Next, the composition helpers from episode 27, so we can chain our tools into readable pipelines instead of nesting calls inside calls inside calls. These are pure higher-order functions -- they take functions and return a new function -- and, pleasingly, they are themselves built on reduce.

F.pipe = function (...fns) {
  return (input) => fns.reduce((value, fn) => fn(value), input); // left to right
};

F.compose = function (...fns) {
  return (input) => fns.reduceRight((value, fn) => fn(value), input); // right to left
};

const process = F.pipe(
  (s) => s.trim(),
  (s) => s.toUpperCase(),
  (s) => `[${s}]`
);
console.log(process("  hello  ")); // "[HELLO]"

Both take a list of functions via the rest parameter ...fns (episode 20) and return a brand-new function that runs the whole chain on whatever input it is given. pipe threads the value through the functions left to right -- trim, then uppercase, then bracket -- which reads in the same order the data flows, and is the one I reach for ninety percent of the time. compose does the exact same thing right to left, which is the traditional mathematical order (f(g(x)) means "do g first"). They differ only in direction; pick whichever reads more naturally for the pipeline at hand. Notice we lean on the real built-in Array.prototype.reduce here rather than our own F.reduce -- either would work, but using the native one keeps pipe and compose independent of the earlier code, which is a small design nicety.

The reason these two little functions matter so much is that they are the glue. On their own, map, filter, and reduce are useful. But it is pipe that lets you line them up into a single readable data flow, turning a tangle of nested parentheses into a top-to-bottom recipe. We will see exactly that in a moment.

Adding curry and memoize

Now the two power tools, from episodes 26 and 29 respectively. curry turns any function into one that will happily accept its arguments one at a time, or several at a time, or all at once. memoize caches a pure function's results so repeat calls are effectively free.

F.curry = function (fn) {
  return function curried(...args) {
    if (args.length >= fn.length) return fn(...args);
    return (...more) => curried(...args, ...more);
  };
};

F.memoize = function (fn) {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}; // one generic cache-wrapper, works on any pure function

const add = F.curry((a, b, c) => a + b + c);
console.log(add(1)(2)(3)); // 6
console.log(add(1, 2)(3)); // 6
console.log(add(1)(2, 3)); // 6

curry uses fn.length -- the number of declared parameters a function has, its arity -- to decide whether it has collected enough arguments yet. If it has (args.length >= fn.length), it calls the original function for real. If not, it returns a new function that remembers the arguments gathered so far (in a closure -- there it is again) and waits for the rest. That is why add(1)(2)(3), add(1, 2)(3), and add(1)(2, 3) all give the same 6: curry does not care how the three arguments arrive, only that all three eventually do.

memoize is exactly the generic version we built last episode, now sitting in our library ready to wrap any pure function. It keeps a private Map in a closure, keyed by JSON.stringify(args), and consults it before doing any real work. Remember the caveats from episode 29 -- the JSON.stringify key is fine for primitive arguments but has sharp edges around object key order and functions -- and remember the golden rule: memoize only pure functions, never impure ones. Both curry and memoize are, at heart, functions that take a function and return an enhanced function. That is the higher-order-function idea (episode 18) doing real, practical work.

Using the library together

Here is the payoff, and honestly the whole reason we bothered. With these seven tools we can express real data processing declaratively -- describing what we want rather than spelling out how to loop -- by combining currying, composition, and our own array functions into one clean pipeline. Watch them cooperate.

// curried helpers that plug straight into a pipe
const multiplyBy = F.curry((factor, n) => n * factor);
const above = F.curry((limit, n) => n > limit);

const summarize = F.pipe(
  (nums) => F.filter(nums, above(2)),          // keep numbers > 2
  (nums) => F.map(nums, multiplyBy(10)),       // multiply each by 10
  (nums) => F.reduce(nums, (a, b) => a + b, 0) // sum them
);

console.log(summarize([1, 2, 3, 4, 5])); // (3+4+5)*10 = 120

Read that pipeline like a recipe written in plain English: keep the numbers above 2, multiply each survivor by 10, then sum the result. Every step is a small, pure function drawn from our own library, and pipe stitches them into a single named operation, summarize, that you can now apply to any array of numbers. Notice how currying pulls its weight here: above(2) and multiplyBy(10) are partially applied functions (episode 26) -- we have locked in the first argument and handed back a one-argument function that is exactly the shape filter and map expect. That is currying and higher-order functions clicking together in one line.

This is what people mean when they talk about "functional JavaScript". Not monads and category theory and scary words -- just small pure functions, composed into readable pipelines, each piece independently testable. The style scales: the same pipe that chains three steps chains thirteen just as happily, and each step stays small enough to hold in your head. That readability is the real prize.

A quick look sideways at the real libraries

Since quite some of you arrived here from the Learn Python Series, it is worth a short glance at how other ecosystems handle this, because it nails down that we have not built a toy -- we have built a miniature of a very standard idea.

In JavaScript, the grown-up versions of our F are Lodash (_.map, _.filter, _.reduce, and the auto-curried lodash/fp variant) and Ramda (R.pipe, R.compose, R.curry, everything curried and data-last by default). They add hundreds of functions, careful performance work, and edge-case handling we skipped -- but the core spirit is identical to ours. Python, meanwhile, bakes a lot of this straight into the language and standard library, which is a nice contrast:

from functools import reduce

nums = [1, 2, 3, 4, 5]
result = reduce(lambda a, b: a + b,          # our F.reduce
                map(lambda n: n * 10,         # our F.map
                    filter(lambda n: n > 2, nums)))  # our F.filter
print(result)  # (3+4+5)*10 = 120

Same three operations, same result as our summarize, just with map and filter built in as lazy iterators and reduce living in functools. Python leans on list comprehensions for a lot of this in practice, but the functional primitives are right there. The portable lesson across both languages: map, filter, and reduce are a universal vocabulary. Learn them once, properly, by building them, and you carry them everywhere. That is exactly why we implemented ours from reduce up rather than just importing a library and waving our hands.

Testing our library

A library nobody trusts is a library nobody uses, so we need tests. And here comes the reward for all that fuss about purity in episode 28: because every function in F is pure, testing is nothing more than comparing an input to an expected output. No setup, no teardown, no mocks, no fiddling with global state -- you call the function and check what comes back. A tiny hand-rolled assertion is more than enough to prove each piece works.

function assertEqual(actual, expected, label) {
  const pass = JSON.stringify(actual) === JSON.stringify(expected);
  console.log(`${pass ? "PASS" : "FAIL"}: ${label}`);
}

assertEqual(F.reduce([1, 2, 3], (a, b) => a + b, 0), 6, "reduce sums");
assertEqual(F.map([1, 2], (n) => n + 1), [2, 3], "map adds one");
assertEqual(F.filter([1, 2, 3], (n) => n > 1), [2, 3], "filter keeps > 1");
assertEqual(F.pipe((n) => n + 1, (n) => n * 2)(3), 8, "pipe chains (3+1)*2");
assertEqual(F.compose((n) => n + 1, (n) => n * 2)(3), 7, "compose chains (3*2)+1");
assertEqual(F.curry((a, b) => a + b)(2)(3), 5, "curry collects args");
// every line prints PASS

Each test states an input, states the output we expect, and checks that they match. assertEqual uses JSON.stringify on both sides so it can compare arrays and objects by value rather than by reference (episode 12 -- two different arrays are never === equal even with identical contents, so we serialise them to compare). That is a crude equality check with all the caveats we met last episode, but for a learning exercise with primitive and plain-array results it does the job perfectly.

Note the compose test gives 7 where the pipe test gives 8, from the very same two functions and the same input 3 -- because pipe does +1 then *2 ((3+1)*2 = 8), while compose does *2 then +1 ((3*2)+1 = 7). A neat little confirmation that direction really is the only difference between them. In a real project you would reach for a proper test runner (that is a Phase 15 topic), but the essence of testing pure code is exactly this: input in, expected out, compare. Purity is what makes it this easy.

So what did we build?

  • A small, complete functional utility library, F, assembled entirely from Phase 2's ideas -- no external dependencies, no magic.
  • reduce implemented directly, then map and filter implemented in terms of reduce, revealing how fundamental reduce really is (and an honest note on the O(n squared) cost of the elegant-but-slow spread version).
  • pipe and compose for chaining functions into left-to-right and right-to-left pipelines -- the glue that turns small functions into readable data flows.
  • curry for flexible, argument-at-a-time functions built on arity and closures, and memoize for caching pure functions.
  • A worked example combining currying, partial application, composition, and our array tools into one clean, declarative pipeline.
  • Dead-simple tests, made trivial by the single fact that every function is pure.

And that closes Phase 2. Take a second to appreciate how far you have come: you now think in first-class functions, closures, higher-order functions, purity, and composition -- the ideas that make JavaScript expressive and that quietly underpin every framework and library you will ever touch. React hooks, Redux reducers, RxJS pipelines, Express middleware chains: all of it is the machinery you just built by hand, dressed up in different clothes. That is not an exaggeration; it is why I spend so much of a JS course here.

Next episode opens Phase 3, where we turn to the other great pillar of the language: objects, prototypes, and classes. We start right at the foundation, with object literals in real depth -- shorthand properties, computed keys, and the property descriptors that quietly control how every property on every object behaves. Different world, same habit of building from first principles.

Thanks for reading, and I'll see you in Phase 3.

scipio@scipio

Learn JS Series (#30) - Mini Project: A Small Functional Utility Li... | Ecency