Audit-derived overview of what fpEs is, where it came from, its full surface area, its non-obvious behaviors, and how to actually use it. Companion to
API.md. Last verified against v1.2.0 (source executed, not inferred, except where marked[INFERENCE]).
fpEs is a zero-runtime-dependency, ES5-targeted JavaScript functional-programming micro-toolkit that bundles five small pillars into one library:
| Pillar | Module | What it gives you |
|---|---|---|
| Optional / Maybe | maybe |
A Fantasy-Land–compatible Maybe for null-safety |
| IO monad + Observable-style subscribe | monadio |
Lazy effects, subscribe, and generator do-notation |
| PubSub | publisher |
A tiny observable event bus |
| Utility belt | fp |
Ramda/Lodash-style point-free collection & function helpers |
| Pattern matching + ADTs | pattern |
either/inCaseOf*, plus SumType/ProductType/TypeADT |
It has no runtime dependencies, ships ES5 bundles for the browser (global fpEs) and
CommonJS modules for Node/npm (package fpes, all lowercase).
- First commit 2018-06-04 by John Lee (
johnteee@gmail.com); primary maintainer throughout (771 commits total, the bulk being Dependabot bumps). - The optional type was originally named
Monad(src/monad.js) and later renamed toMaybe(bd146f0"Correct the name of Maybe", ~12 days later). - Build order (from
git log --reverse): Maybe (as Monad) → curry/compose → MonadIO → Publisher → PatternMatching → SumType → the largefputility expansion (2018-06-08..17). - Second contributor alf (
alfmohenu) added many of the array utilities (compact,concat,difference,drop,fill,findIndex,findLastIndex,head,fromPairs,initial,intersection,join,nth,sortedIndex,union,zip, ...). - The README credits purify as inspiration for the Fantasy-Land Maybe implementation.
Author-stated motivation (README "Why"): wanted "Optional & Rx-like & PubSub functions," but combining full stacks was "too heavy," so fpEs ships "the core functions" only.
Note on "Rx-like": this is the author's own word, but
MonadIOis a one-shot lazy IO monad with an Observable-stylesubscribe, not an RxJS-style multi-value stream library. Treat it as IO + do-notation, not as reactive streams. The framing "one small lib instead of Ramda + RxJS + PubSub" is a reasonable shorthand, but only Optional/Rx-like/PubSub are author-claimed — the "Ramda" mapping is[INFERENCE].
- Node/npm: install
fpes(lowercase) and import lowercase subpaths:require('fpes'),require('fpes/maybe'),require('fpes/fp'), etc. - Browser bundle: the UMD global is
fpEs(mixed case). See thedist/*.min.jsfiles. - The mixed-case project name
fpEsis the brand; the installable package isfpes.
index.js builds a flat namespace — the whole fp and pattern surfaces are spread in
alongside the three facade constructors:
module.exports = {
Maybe: require('./maybe'),
MonadIO: require('./monadio'),
Publisher: require('./publisher'),
...require('./fp'), // ~59 utilities spread to top level
...require('./pattern'), // ~39 matchers/types spread to top level
};So require('fpes') gives you Maybe, MonadIO, Publisher, and every fp + pattern
export at the top level. Subpath imports (fpes/fp, fpes/pattern) return that module directly.
- The README documents only a small fraction of the real API. Using an honest
name-mention test (discarding substring false positives like "apply", "toolchain",
"features of"), roughly 13 symbols are documented vs ~37 undocumented across the
facades, and in
fponly 3 of 59 exports (compose,curry,map) appear in the README. The rest are fully working, tested, public API — just README-silent. - "Leaked" is the author's own term. Commit
7a4afceis literally titled "Add some leaked common functions" (addedget,matches,memoize), andmaybe.jscarries the comment "Prevent avoiding aliases in case of leaking." In this project "leaked" means exported-but-undocumented surface, not a security leak. SeeAPI.mdfor the full catalog. fphas no true "leaked" noise — all 59 exports are intentional public API. The genuine internal-only surface to avoid depending on is small:Maybe.ref,MonadIO.effect,Publisher.subscribers,MonadIO.wrapGenerator, and the rawPattern/Matchableclasses.- Maybe is Fantasy Land compliant and exposes
fantasy-land/*aliases (Maybe only —fpandmonadiohave no such aliases). It interoperates with the Fantasy Land ecosystem. - Everything below was runtime-verified against v1.2.0 source; the gotchas and scenarios
are executed, not inferred (exceptions marked
[INFERENCE]).
All nine are intended behavior, locked-by-tests quirks, or design limitations — none are open bugs (the genuine wrong-result bugs were fixed in v1.2.0). Items 1–6 are quirks/design; items 7–9 are footguns to avoid. Work with them; do not expect them to change.
Maybe.apuses Fantasy Land argument order. The receiver holds the value, the argument holds the wrapped function:Maybe.of(10).ap(Maybe.of(x => x + 1)).unwrap(); // 11 // Maybe.of(fn).ap(Maybe.of(10)) throws — wrong order
fp.notdoes not curry the way you'd expect. Its signature is(fn, ...args) => !fn(...args); rest params makecurrysee arity 1, sonot(pred)fires immediately as!pred(). Call it in one shot:not(pred, x)→!pred(x).fp.nthnegative indices are mirror-flipped vs Ramda/Lodash (locked by tests attest/fp.js:314and:769-777).nth([1,2,3,4], -1)→ 1 (the first element), andnth(list, -list.length)→ the last. The JSDoc "starting from the right" is misleading; negativenreverses then indexes atlength + n.Maybe.letDois bind-like;or/orDoare no-ops onSome. On a present value,orandorDoreturn the sameMaybe(no fallback runs);letDo(fn)maps the value. OnNone, it's the mirror:letDois a no-op,or/orDosupply the fallback. UseorDo(() => def)for defaults, notoron a value you expect to be present.MonadIOis lazy.map/then/flatMaponly build an effect; nothing runs until.subscribe(). Verified: a side effect stayed atran === 0after building the pipeline and became1only aftersubscribe. Passsubscribe(fn, true)for async execution.- Fantasy Land aliases are Maybe-scoped.
fantasy-land/map,.../chain,.../ap,.../of,.../equals, etc. exist onMaybeonly. MonadIO.fromPromise(p).map(fn)does NOT await — it maps over the Promise object.MonadIOis a synchronous IO monad;map/thenapplyfnto the raw effect result. For afromPromisemonad that result is a Promise, sofnreceives the Promise, not the value:Bridge async throughMonadIO.fromPromise(Promise.resolve(10)).map(x => x + 1).subscribe(v => v, true); // → "[object Promise]1" (NOT 11) — silent wrong result
doMinstead, which awaits eachyield:UseMonadIO.doM(function* () { const v = yield MonadIO.fromPromise(Promise.resolve(10)); return v + 1; }); // → Promise resolving to 11
fromPromiseonly (a)yielded insidedoM, or (b) viasubscribe(fn, true)with no intervening.map. Locked by characterization tests.flatMap/chaincallbacks MUST return the same monad.Maybe.of(5).flatMap(x => x*2)returns the raw number10, not aMaybe(so.unwrap()throws);MonadIO.flatMapwith a scalar callback throws (fn(result).effect is not a function). Usemap/thenfor scalar transforms,flatMap/chainonly when your callback returns aMaybe/MonadIO.- Some
fphelpers throw on out-of-domain input rather than coercing.chunk(list, 0)andchunk(list, -1)throwRangeError;range(-2)throwsRangeError(thoughrange(0)→[]);contains(str, x)throwsTypeError(it usesArray.prototype.reduce, so pass an array, not a string). Guard these inputs at the call site.
All snippets below were executed against v1.2.0 (outputs shown are real) unless marked
[INFERENCE].
Deep x && x.y chains and || defaults wrongly treat 0/'' as missing. Maybe makes
absence explicit and keeps a single pipeline:
import Maybe from 'fpes/maybe';
function resolvePort(env) {
const positive = Maybe.fromPredicate(n => n > 0);
return Maybe.fromFalsy(env.PORT) // '' / undefined → None
.map(Number)
.chain(positive) // flatMap alias; None if <= 0
.filter(n => n < 65536)
.orDo(() => 3000) // None → fallback
.unwrap();
}
// {} → 3000 · {PORT:''} → 3000 · {PORT:'8080'} → 8080 · {PORT:'0'} → 3000 · {PORT:'99999'} → 3000Assemble a job without running it; execute exactly when you subscribe (great for testability):
import MonadIO from 'fpes/monadio';
let ran = 0;
const job = MonadIO.just({ rows: 10 })
.map(cfg => ({ ...cfg, batch: 100 }))
.flatMap(cfg => MonadIO.of(() => { ran++; return cfg.rows * cfg.batch; }))
.flatMap(run => MonadIO.of(run()));
// ran === 0 here — nothing executed yet
job.subscribe(result => console.log(result)); // ran === 1, result === 1000
job.subscribe(result => console.log(result), true); // async (Promise) executiondoM runs a generator where each yield awaits a Promise, a MonadIO, or a Maybe. Yielding
a None short-circuits:
import Maybe from 'fpes/maybe';
import MonadIO from 'fpes/monadio';
const { doM, promiseof, fromPromise } = MonadIO;
const checkout = userId => doM(function* () {
const id = yield promiseof(userId);
const user = yield Maybe.fromPredicate(x => x != null, id); // None aborts the flow
const cart = yield fromPromise(fetch(`/api/cart/${user.unwrap()}`).then(r => r.json()));
return { user: user.unwrap(), items: cart.items };
});
checkout(42).then(console.log); // Promise resolving to the combined resulteither + inCaseOf* + otherwise replaces switch(typeof …) with an explicit, defaulted
match:
import { either, inCaseOfNull, inCaseOfString, inCaseOfObject, otherwise } from 'fpes/pattern';
const normalize = raw => either(raw,
inCaseOfNull(() => ({ type: 'noop' })),
inCaseOfString(s => ({ type: 'text', value: s })),
inCaseOfObject(o => o.type ? o : { type: 'unknown', payload: o }),
otherwise(() => ({ type: 'fallback' })));
// null → {type:'noop'} · 'hi' → {type:'text',value:'hi'}
// {type:'click',id:1} → passthrough · {a:1} → {type:'unknown',payload:{a:1}}
eitherthrows if nothing matches and nootherwiseis given — always provideotherwisefor open inputs.
Declare a schema once as an algebraic data type and reuse it in handlers and tests:
import { TypeADT, TypeString, TypeNumber } from 'fpes/pattern';
const User = TypeADT({ name: TypeString, age: TypeNumber });
User.matches({ name: 'Ada', age: 36 }); // true
User.matches({ name: 'Ada' }); // falseSumType/ProductType compose these into tagged unions and records (see
API.md). [INFERENCE] for the exact SumType.apply ergonomics beyond matches.
A tiny PubSub where map returns a derived publisher:
import Publisher from 'fpes/publisher';
const bus = new Publisher();
const seen = [];
bus.map(x => x * 2).subscribe(v => seen.push(v));
bus.publish(5); // seen: [10]
bus.publish(6); // seen: [10, 12]
bus.publish(7, true); // async publish (Promise-scheduled)
// bus.unsubscribe(fn) / bus.clear() to detachThe README shows only compose/curry, but the full utility belt is available:
import { pipe, chunk, zip, fromPairs, sortedUniq, memoize, trampoline } from 'fpes/fp';
pipe(x => x + 1, x => x * 2)(5); // 12 (left-to-right; compose is right-to-left)
chunk([1,2,3,4,5], 2); // [[1,2],[3,4],[5]]
zip([1,2],[3,4]); // [[1,3],[2,4]]
fromPairs([['a',1],['b',2]]); // {a:1, b:2}
sortedUniq([3,1,2,1,3]); // [1,2,3]
const slow = memoize(x => x * 2); // caches by args
const sum = trampoline(function rec(n, acc = 0) { // stack-safe recursion
return n === 0 ? acc : () => rec(n - 1, acc + n);
});
sum(1000000); // no stack overflowAPI.md— full export tables, curry status, and the leaked/alias appendix.../README.md— install, browser bundles, tutorial examples.../CHANGELOG.md— versioned behavior changes.