Skip to content

fp

Terminal window
bun add @stopcock/fp

@stopcock/fp is a focused functional-programming toolkit for TypeScript. It provides typed data types, eager and lazy collections, algebra, optics, validation, pattern matching, recursion schemes, and a portable pipeline compiler without taking ownership of your application runtime.

The root is intentionally small:

import { err, flow, none, ok, pipe, some } from '@stopcock/fp'

compile, compilePure, dual, and explain are not root exports. Import them from the subpaths that name what they are: @stopcock/fp/compile, @stopcock/fp/dual, and @stopcock/fp/fusion/debug.

Specialist APIs live in tree-shakeable subpaths:

import * as A from '@stopcock/fp/array'
import * as Iter from '@stopcock/fp/iter'
import * as O from '@stopcock/fp/option'
import * as R from '@stopcock/fp/result'
import * as V from '@stopcock/fp/validation'
import * as Optic from '@stopcock/fp/optic'
import { pipe } from '@stopcock/fp'
import * as A from '@stopcock/fp/array'
const activeNames = pipe(
players,
A.filter((player) => player.active),
A.map((player) => player.name),
A.take(10),
)

Every operator has one form: curried, data-last.

const doubled = A.map((value: number) => value * 2)([1, 2, 3])
const alsoDoubled = pipe([1, 2, 3], A.map((value) => value * 2))

Array operations are immutable by default. Explicit *Into operations write into caller-owned storage, while view-named operations are the only APIs allowed to alias an input.

There is one runtime path: pipe, flow, compile, and compilePure are all the same plain, left-to-right, sequential application. compile exists so a call site can say “this is a pipeline I intend to compile” by name; uncompiled, it behaves exactly like pipe, including callback order and early-exit counts.

import { compile } from '@stopcock/fp/compile'
const activeNames = compile(
A.filter((player: Player) => player.active),
A.map((player) => player.name),
A.take(10),
)
activeNames(players)

The actual fusion is @stopcock/fp-compiler: a build-time transform that recognizes a pipe/flow/compile call over these operators and replaces it with an inlined, single-pass loop, so the runtime engine above never runs for that call at all. compilePure exists for source parity with the compiler’s assumePure option; at runtime it is compile under a different name.

import { explain } from '@stopcock/fp/fusion/debug'
explain(
A.map((value: number) => value * 2),
A.filter((value) => value > 0),
) // 'sequential', always -- a call the compiler actually fused never
// reaches this code to be explained

Uncompiled pipe is already at or near hand-loop speed for most chains, so you do not need the compiler to get reasonable performance. Reach for @stopcock/fp-compiler for pipelines you’ve actually measured as hot.

Use Option for absence, Result for fail-fast recoverable errors, and Validation when independent checks should accumulate all errors.

import { pipe } from '@stopcock/fp'
import * as O from '@stopcock/fp/option'
import * as R from '@stopcock/fp/result'
import * as V from '@stopcock/fp/validation'
const port = pipe(
O.fromNullable(process.env.PORT),
O.map(Number),
O.filter((value) => Number.isInteger(value) && value > 0),
O.getOrElse(() => 3000),
)
const parsed = R.tryCatch(
() => JSON.parse(rawConfig) as Config,
(cause) => ({ type: 'InvalidJson' as const, cause }),
)
const fields = V.all([
V.fromPredicate((value: string) => value.length > 0, () => 'name is required')(name),
V.fromPredicate(Number.isFinite, () => 'age must be finite')(age),
] as const)

Asynchronous failure belongs in @stopcock/async/task; there is deliberately no competing AsyncResult type.

Iter is the single lazy synchronous sequence abstraction:

import { pipe } from '@stopcock/fp'
import * as Iter from '@stopcock/fp/iter'
const squares = pipe(
Iter.range(1, Infinity),
Iter.map((value) => value * value),
Iter.filter((value) => value % 2 === 0),
Iter.take(5),
Iter.toArray,
)

See Iter, Option and Result, Validation, Optics, and the complete module catalogue.