How @stopcock/fp-compiler fuses your loops
Updated for
@stopcock/fp2.0. The runtime-JIT design was removed first, then the portable runtime fusion engine that replaced it was removed too.pipeis now plain sequential function application. Read on for what replaced it.
Ordinary array chains allocate intermediate arrays:
const result = users .filter((user: User) => user.active) .map((user: User) => user.name) .slice(0, 10)import { pipe } from '@stopcock/fp'import * as A from '@stopcock/fp/array'
const result = pipe( users, A.filter((user: User) => user.active), A.map((user: User) => user.name), A.take(10),)pipe doesn’t change that on its own. It runs each step in order: filter
produces a full intermediate array, then map produces another, then
take slices it. Nothing fuses at runtime any more. If you were relying on
pipe to fuse a hot chain for you, it won’t — you need the compiler.
The compiler fuses this at build time
Section titled “The compiler fuses this at build time”@stopcock/fp-compiler is a Vite/Rollup/esbuild plugin. It recognizes a
statically-imported pipe/flow/compile chain built from its supported
array operators and rewrites it into a single inlined loop before your code
ships. The call above compiles to roughly:
const output: string[] = []for (let index = 0; index < users.length; index++) { const user = users[index] if (!user.active) continue output.push(user.name) if (output.length === 10) break}There’s no eval, new Function, function-source parsing, or dynamically
loaded JIT chunk anywhere in this. The rewrite happens once, during your
build, by reading and transforming the source. The same output stays valid
under strict CSP and across Node, Bun, Deno, browsers, workers, and edge
runtimes.
Compile once for repeated work
Section titled “Compile once for repeated work”For a hot pipeline used across many inputs, compile marks the call site
so the plugin knows to fuse it. Uncompiled, compile is identical to
pipe — it changes nothing by itself:
import { compile } from '@stopcock/fp/compile'import { explain } from '@stopcock/fp/fusion/debug'
const activeNames = compile( A.filter((user: User) => user.active), A.map((user: User) => user.name), A.take(10),)
activeNames(firstPage)activeNames(secondPage)
console.log( explain( A.filter((user: User) => user.active), A.map((user: User) => user.name), A.take(10), ),)// 'sequential' -- explain() runs at runtime, and a call the compiler// actually fused at build time never reaches this code to be explainedcompilePure is compile under a different name, for source parity with
the compiler’s assumePure option: it permits a documented set of pure
rewrites (like collapsing an adjacent sortBy -> take(k) into a bounded
top-k pass) only when the plugin actually fuses the site.
What compiles and what doesn’t
Section titled “What compiles and what doesn’t”Item-by-item steps — map, filter, reject, filterMap, take,
takeWhile, drop, dropWhile, flatMap — can share one fused loop.
Whole-input operations — sort, sortBy, reverse, uniq, flatten,
scan — need the complete array first, so they end one loop and start
another. Terminals — reduce, count, sum, find, and the other
short-circuit searches — can finish a fused loop without another
intermediate array.
An unsupported operator, a dynamic step, a lexically shadowed import, or
anything else the compiler can’t resolve statically leaves the call site
untouched: an ordinary runtime call to pipe, exactly as if the plugin
weren’t installed. Run stopcock check --strict to see which sites in your
own source compiled and which bailed, and why.
When it’s worth compiling
Section titled “When it’s worth compiling”Measure your workload. Compiling helps most for large inputs, early exit,
several compatible stages, and hot repeated pipelines. For a short
collection or a single native operation, an uncompiled pipe is already
close to hand-loop speed — see Benchmarks for
the measured comparison against lodash, ramda, remeda, rambda, and
ts-belt.