Genericity for free

3 Nov, 2026 · 6 min read
Contents

Functional structures give us genericity: an operation written once, instead of rewritten for every container. JavaScript keeps shipping the specialized version without the general one, and we pay for it in duplicated APIs and glue code.

Genericity for free

This series argues that an API should be complete against the structure its domain prescribes. Generalization as discipline says to look for the complete shape, and Math-grounded APIs and CRUD: REST without the HTTP show two places to find it. This post turns the argument on the language itself. Containers share most of their API and rarely care about their items, as the second post noted, so their common operations can be written once. When a language skips that step, every new container re-derives the same operations by hand.

Is it academic?

Mention a monad or a functor, and the reflex is predictable: this is academic, Haskell tourism with no use in a working codebase. The reflex is earned. The material is usually taught laws first, with the uses left for later.

The claim here is an engineering one, and it is about duplication. Name the shared structure once, and every container inherits its operations. Skip it, and each container gets a hand-written copy, and the copies drift apart.

Functional structures give us two things. Genericity is the operation written once. Proven combinations are the laws doing work: when structures compose by their laws, the combination is guaranteed, so the adapters, the shim layers, and the “convert A to B so C can consume it” code go unwritten. So this post counts its costs in glue code written and adaptations maintained. A reader who doesn’t care whether a law holds still cares about hand-writing Promise.all for a new container.

We have already seen the payoff without a single monad. In the charting library from Math-grounded APIs, each geometric action became one matrix, and a composite one, such as rotating around a point, multiplied into a single matrix before it touched the shape. Users combined the actions without knowing it was matrix multiplication. The shared structure was named once, and composition came with it.

What a monad asks for

A monad is a wrapper with two operations. of wraps a plain value. flatMap runs a function that returns a wrapped value, and flattens the result into one wrapper. Three laws keep the pair consistent:

// left identity
of(a).flatMap(f)          // same as f(a)
// right identity
m.flatMap(of)             // same as m
// associativity
m.flatMap(f).flatMap(g)   // same as
m.flatMap(x => f(x).flatMap(g))

Arrays qualify. Array.of and flatMap obey all three laws:

const f = x => [x, x * 10];
Array.of(3).flatMap(f);   // [3, 30]
f(3);                     // [3, 30]

Promise could have been a monad

Promise has a candidate for each operation: Promise.resolve for of, and then for flatMap. Two design decisions break the laws.

The first: then fuses map and flatMap. A callback may return a plain value or a promise, and the result is flattened either way. So a promise of a promise cannot exist, and there is no join to flatten one by hand:

const inner = Promise.resolve(1);
Promise.resolve(inner) === inner;     // true
await Promise.resolve(1)
  .then(x => Promise.resolve(x + 1)); // 2, unwrapped

The second: then adopts any object with a then method, a “thenable”. That breaks left identity:

const f = x => Promise.resolve(typeof x);
const a = {then: resolve => resolve(42)};

await f(a);                       // 'object'
await Promise.resolve(a).then(f); // 'number'

f receives the result of running a, never a itself.

Both were choices. Flattening gave ergonomics. Adopting thenables let promise libraries work together: the Promises/A+ specification says it “allows promise implementations to interoperate”. When its authors were asked in 2013 to adopt the monad operations, the answer was that “changing then simply is not an option.”

The counter-argument deserves its full weight. A lawful Promise makes a promise of a promise observable, and then await becomes ambiguous: does it unwrap one level or all of them? That costs the ergonomics that got promises adopted at all, and the lawful version might not have won. This argument covers then and leaves the combinators untouched.

Promise.all is the tell

Promise.all turns a container of promises into a promise of a container. In functional vocabulary that operation is sequence, written once for every container that can be walked item by item and rebuilt. JavaScript wrote it for arrays. It accepts any iterable, but it awaits the items themselves and always returns an array. A Map iterates as [key, value] pairs, which are not promises, so they pass through as they are:

const m = new Map([['a', Promise.resolve(1)]]);
await Promise.all(m); // [['a', Promise]], still wrapped

So each container gets its own glue:

const allMap = async map => new Map(
  await Promise.all([...map].map(
    async ([k, v]) => [k, await v])));

const allObject = async obj => Object.fromEntries(
  await Promise.all(Object.entries(obj).map(
    async ([k, v]) => [k, await v])));

These are one idea written twice, and the next container needs a third copy. With sequence defined once, the same calls would read:

// hypothetical: one function for any container
await sequence([p1, p2]);            // [v1, v2]
await sequence(new Map([['a', p]])); // Map {'a' => v}
await sequence({a: p1, b: p2});      // {a: v1, b: v2}

Arrays are monads too, so an array and a promise could share one structure, and Promise.all would need no special case. The language accreted four combinators instead: all, allSettled, race, and any. They are special cases of two general ideas, sequencing and a choice of how to combine the results, and none of them can give back a Map.

It is happening right now

ES2025 added iterator helpers, and current Node ships them: map, filter, reduce, and other methods we know from arrays, now on built-in iterators and generators. That is a second hand-written copy of one method set. Async iterator helpers are a Stage 2 proposal, the third copy:

[1, 2, 3].map(x => x * 2);          // Array
[1, 2, 3].values().map(x => x * 2); // Iterator, ES2025
// AsyncIterator: Stage 2, so far

The helpers are useful, and shipping them was right. A Set has no map, but its iterator now does: new Set(set.values().map(f)). In CRUD: REST without the HTTP they let an infinite Fibonacci generator answer filter and take like a stored collection. The complaint is how they arrived: each set was written by hand for one family of containers, so each new family waits for its own copy.

Where the complaint stops

Generators are incomplete too, and here the excuse is real. A suspended generator cannot be copied, so each suspension point can be resumed once, as Exotic goto showed.

The obstacle is semantic. Clones would share the closure’s variables, a finally block would run once per clone, and side effects already performed cannot be rewound. That is why generators in JavaScript and Python are one-shot. Multi-shot continuations exist, Scheme’s call/cc among them, and they are hard to reason about even there. I can see why the language stops here: copying a suspended stack is probably hard, maybe close to impossible.

So the two cases differ. Promise broke the laws by choice, and the choice could have gone the other way. The generator’s limit follows from what a generator is.

Summary

  • Functional structures pay off as code written once: an operation defined for a structure works for every container that has it.
  • A missing abstraction is paid for in duplicated APIs and glue code, and each new container type needs another copy.
  • Where a constraint is real, as with generators, the complaint stops.