Skip to content

How @stopcock/fp-compiler fuses your loops

Updated for @stopcock/fp 2.0. The runtime-JIT design was removed first, then the portable runtime fusion engine that replaced it was removed too. pipe is 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.

@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.

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 explained

compilePure 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.

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.

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.