fxdart
A functional programming library for Dart, ported from FxTS. Lazy evaluation, concurrent async iteration, and pipeline-style composition โ the FxTS programming model, rebuilt on Dart's type system.
// 6 requests of 1s complete in ~2s โ not ~6s.
await fx(userIds).toAsync().map(fetchUser).concurrent(3).toList();
๐ Try it in your browser
๐ Click any badge above. Each one is a live, runnable site:
| Site | What it is | |
|---|---|---|
| ๐ | FxDart 101 | A guided course with an in-browser playground for every function |
| ๐ | Daily Ledger | A full app built with fxdart, running live |
| โ๏ธ | Dart vs FxDart | 53 problems solved both ways, with an honest verdict on each |
| โก | RxDart vs FxDart | The same 50-example format vs RxDart โ push streams vs pull pipelines, including the cases where RxDart is simply the right tool |
๐ Contents
โจ Why fxdart? ยท ๐ฆ Install ยท ๐ค AI agent skills ยท ๐ ๏ธ Usage ยท ๐ API overview ยท ๐ Differences from FxTS ยท ๐งช Testing ยท ๐ Acknowledgments
โจ Why fxdart?
๐ฆฅ Lazy evaluation
Operators build a pipeline and do no work until a terminal operator runs โ so
fx(hugeList).map(f).filter(g).take(3) only ever computes 3 results.
๐ Concurrency you can dial
concurrent(n) evaluates the upstream chain n items at a time while preserving
order โ turning six 1-second requests into a ~2-second batch with one method call.
๐ก๏ธ Type-safe pipelines
The fx() chain keeps full static typing end to end. Sync operators are plain
functions over native Iterables, so everything interops with ordinary Dart code.
๐ง One mental model for sync and async
The same operator names work on Iterable (sync) and FxAsyncIterable (async),
with Stream bridges in both directions.
๐ฏ Typed errors
Kotlin Arrow 2.x's Raise/Either approach, ported: straight-line either blocks
instead of flatMap pyramids, error accumulation with NonEmptyList, and validation
fused directly into the concurrent pipelines above.
โก A push side too, when time matters
Pull pipelines model data over demand; fxEvents() models events over time on
plain Dart Streams โ debounce, throttle, sample, switchMap, combineLatest
and friends โ then hands you back to the typed pull world with .pull().
๐ง Dart names work too
Every FxTS name that Dart's collections already have a word for is also callable by
that word: where, expand, flattened, nonNulls, sorted, indexed,
firstWhereOrNull. No dialect to learn before you can read the code.
๐ฆ Install
See the installation guide on pub.flutter-io.cn for the latest version.
๐ค AI agent skills
fxdart ships three Agent Skills that teach AI coding
assistants โ Claude Code, Codex, Devin, Antigravity, OpenCode, pi, and
anything reading .agents/skills/ โ when and how to use fxdart:
| Skill | Covers |
|---|---|
๐ skills/fxdart-pipelines/ |
Collections, concurrent Futures, and complex pull flow logic |
โก skills/fxdart-events/ |
Events over time: fxEvents, debounce, switchMap, combineLatest, the pull seam |
๐ฏ skills/fxdart-typed-errors/ |
The typed-error system: either blocks, error accumulation, Either on pull and on events |
Option A โ the community skills CLI (auto-detects your IDE/agent):
dart pub global activate skills
skills get fxdart
Option B โ fxdart's built-in zero-dependency installer:
# From a project that depends on fxdart:
dart run fxdart:install_skills # auto-detects agent dirs in the project
dart run fxdart:install_skills claude codex # or name agents explicitly
dart run fxdart:install_skills all --global # per-user dirs (~/.claude/skills, ~/.agents/skills, ...)
# Or standalone:
dart pub global activate fxdart
fxdart_skills --global claude
Supported agents:
| Agent | Install dir |
|---|---|
claude |
.claude/skills/ |
codex / antigravity / generic |
.agents/skills/ |
devin |
.devin/skills/ |
opencode |
.opencode/skills/ |
pi |
.pi/skills/ ยท global ~/.pi/agent/skills/ |
๐ก
--listshows install status ยท--removeuninstalls.
๐ ๏ธ Usage
๐ Sync pipelines
Sync operators are data-first functions over lazy Iterables; the fx() chain
composes them with full type inference:
import 'package:fxdart/fxdart.dart';
fx([1, 2, 3, 4, 5])
.map((a) => a + 10)
.filter((a) => a % 2 == 0)
.toList(); // [12, 14]
// The same chain as a getter, reached from the collection itself:
[1, 2, 3, 4, 5].fx
.map((a) => a + 10)
.filter((a) => a % 2 == 0)
.toList(); // [12, 14]
// Equivalent with top-level functions:
toList(filter((a) => a % 2 == 0, map((a) => a + 10, [1, 2, 3, 4, 5])));
// Laziness: only 3 squares are ever computed.
fx(range(1, 1000000)).map((a) => a * a).take(3).toList(); // [1, 4, 9]
Every entry point has a getter twin, and the name always carries fx so it is
clear which library you are stepping into: .fx on an Iterable,
FxAsyncIterable or Stream, .fxAsync on an iterable of futures, .fxEvents
and .fxLive on a Stream, .fxShuffle on an Iterable, .fxDebounce /
.fxThrottle on a callback. It builds the same thing and reads left to right
when the source is itself a call: orders.where(isPaid).fx.groupBy(...). The
operators stay on the chain rather than on Iterable โ fifteen of them share a
name with a member Iterable already has, and an instance member always wins.
The docs use the function spellings throughout; the
fx() tutorial has the
full roster.
โณ Async pipelines
Async operators work on FxAsyncIterable<T> โ a pull-based protocol ported from
FxTS's AsyncIterable handling. Lift values in with toAsync / fromStream
(or .toAsync() on a chain), and out with .toList() / .toStream():
await fx([1, 2, 3, 4])
.toAsync()
.map((a) async => a + 10) // callbacks may be async
.filter((a) => a % 2 == 0)
.toList(); // [12, 14]
// Streams bridge both ways.
await fxStream(Stream.fromIterable([1, 2, 3])).map((a) => a * 2).toList();
โก Concurrency
concurrent(n) is FxTS's signature feature, ported faithfully: a concurrency
marker travels backwards through the pipeline's iterator protocol, so the
upstream chain evaluates n items at once while results stay in order.
// 6 requests of 1s complete in ~2s instead of ~6s.
await fx([1, 2, 3, 4, 5, 6])
.toAsync()
.map((id) => fetchUser(id))
.concurrent(3)
.toList();
- ๐ฅ
concurrentPool(n)โ the completion-order variant: faster first results, no ordering guarantee.
โน๏ธ This back-channel protocol is why fxdart has its own
FxAsyncIterableinstead of building on push-basedStreams, which cannot express it.
๐ก Events (the push side)
Some problems really are events over time, not data over demand. fxEvents()
wraps a plain Dart Stream in a chainable, Rx-flavoured API โ a thin wrapper, never
an extension, so it coexists with rxdart without member conflicts:
final results = await fxEvents(keystrokes)
.debounce(const Duration(milliseconds: 160))
.switchMap((q) => search(q).asStream()) // cancels the superseded search
.toList();
// Cross back into the typed pull world at any point:
await fxEvents(ticks).sampleOn(clock).pull().map(load).concurrent(4).toList();
LiveValue holds a current-value stream, and FxSubscriptions cancels a bag of
subscriptions together. See โก RxDart vs FxDart
for 50 worked examples โ including the cases where RxDart is the better fit.
๐ฏ Typed errors
The either builder runs a block in a Raise<E> scope: each r.bind unwraps
a success or short-circuits the whole block with a typed failure โ the
Kotlin Arrow 2.x model, ported (no TaskEither/IO wrapper tower, no
Option; Dart's T? plus the nullable builder covers absence):
Either<String, int> parsePort(String raw) => either((r) {
final n = r.ensureNotNull(int.tryParse(raw), () => '"$raw" is not a number');
r.ensure(n > 0 && n < 65536, () => '$n is out of range');
return n;
});
// Validation accumulates EVERY failure into a NonEmptyList, not just the first:
final user = either<Nel<String>, User>((r) => r.zipOrAccumulate2(
(r) => validateName(r, input), (r) => validateAge(r, input), User.new));
// And it fuses with pipelines โ fail-slow, 8 records in flight, order kept:
final result = await fxStream(records)
.mapOrAccumulate<String, User>((r, rec) => parseUser(r, rec), concurrency: 8);
๐ Every subject has a detailed tutorial with an in-browser playground:
๐บ๏ธ overview ยท
โ๏ธ Either ยท
๐ฌ either & the Raise scope ยท
โ nullable ยท
๐ NonEmptyList ยท
โ accumulation ยท
๐ Either ร pipelines
๐ API overview
๐ Differences from FxTS
Dart has no function overloads, variadic generics, or conditional types, so some APIs deliberately deviate:
| FxTS | fxdart | |
|---|---|---|
| ๐ | curried data-last (map(f) inside pipe) |
fx() chain (typed) or dynamic pipe(value, [closures]) |
| ๐ | one map dispatching sync/async |
map (Iterable) / mapAsync (FxAsyncIterable); chains use plain names |
| ๐ | reduce(f, seed, iter) overload |
fold(seed, f, iter) (unseeded reduce(f, iter) unchanged) |
| ๐ฆ | tuples (zip, entries, partition) |
Dart records: (A, B) |
| ๐๏ธ | TS objects (omit, pick, evolve, โฆ) |
Map-based equivalents |
| โ | undefined |
null (head/find/nth return T?) |
| ๐ | toArray / toArrayAsync |
toList / toListAsync (Dart has no array type) |
| โณ | AsyncIterable / for await |
FxAsyncIterable + toStream() / fromStream() bridges |
| ๐๏ธ | variadic zip/juxt/cases |
fixed arities (zip/zip3) or list/record parameters |
| ๐ | curry(f) |
.curried / .uncurried extension getters โ see WHY_CURRIED.md |
๐ Why .curried instead of curry?
FxTS's curry needs arity reflection and recursive conditional types, which
Dart lacks โ so fxdart curries through per-arity extensions instead, resolved
statically and fully typed:
int add(int a, int b) => a + b;
final addOne = add.curried(1); // int Function(int)
fx([1, 2, 3]).map(addOne).toList(); // [2, 3, 4]
๐ WHY_CURRIED.md tells the full design story: why the direct
port is impossible, how static extension resolution stands in for overloading,
why the getter is named curried, and how the same port-the-meaning
philosophy resolves the other unportable APIs.
โ ๏ธ Those APIs keep
@Deprecatedstubs (curry,isUndefined,isArray,isObject,takeUntil) so migrating code gets analyzer guidance instead of silent breakage.
๐งช Testing
The FxTS spec suite has been ported alongside the library, and grown well past it: 1,800+ tests across 170 files, covering sync/async behavior, error propagation, laziness, typed errors, the events layer, and concurrency timing across every operator.
dart test
๐ Coverage is measured on every push and pull request and reported to Codecov. To reproduce locally:
dart run coverage:test_with_coverage # writes coverage/lcov.info
๐ Benchmarks
Two suites back the comparison sites linked at the top:
Dart vs FxDart (53 cases)
and RxDart vs FxDart
(41 cases). Every case is AOT-compiled (dart compile exe) and each side runs
as a fresh process, interleaved, so thermal drift lands on both equally.
./benchmark.sh is the entry point.
Which command, when
1 ยท While developing โ "did my change move this case?"
./benchmark.sh --ab ledger-diff # against HEAD
./benchmark.sh --ab --ref v0.8.5 ledger-diff # against a tag or commit
The one you will reach for most. It builds both variants of lib/ and runs
them interleaved in one session, so drift hits both sides. The native
side is the control: it links no fxdart code, so a library-only change must
leave it identical โ if it moved, the row is void.
It runs 20 rounds rather than ab_bench's default 12, because 12 is not
enough. Four readings in the 0.8.6 pass looked like solid ยฑ3-4% results
against clean controls and every one of them was gone at 20.
2 ยท Before merging or releasing โ "did anything regress?"
./benchmark.sh --ab --all
The same instrument across every case, as a gate: if a control drifts past its
limit the run fails rather than printing a number. --all exists because
without it every slug had to be typed by hand, which made "nothing regressed
by 3%" a claim rather than a check. Give it an idle machine โ it takes a
while.
3 ยท Publishing โ updating the numbers the site shows
./benchmark.sh --docs # sweep, regenerate the report, rebuild docs/
./benchmark.sh --docs --rx # the RxDart family
The only output fit to publish: native and fxdart are measured in the same
session, so each row's ratio is sound. It writes
benchmark/results/results.json (the bar charts), SUMMARY.md, and
perf_ratio_report.md โ every case ordered slowest to fastest. The RxDart
family writes results-rx.json and SUMMARY-RX.md; its pages carry bars but
no ranking table, so there is no report to regenerate there.
Skip --docs and the site keeps showing the old numbers. Skip the report
regeneration โ which is why this mode always does it for you โ and
results.json and the report drift apart silently, which has happened, and
surfaced months later looking like a regression that had just landed.
โ ๏ธ Reading the numbers
Do not compare two sweeps to judge a change. Cross-run noise is about 5%,
and the proof is built in: the native side must be byte-identical across a
library-only change, yet its measured cross-run delta is a median โ2.1%,
ranging โ27% to +4%. Judge changes with 1, publish with 3.
--smoke is for "does this still run" โ one un-warmed iteration, and the
script restores results.json afterwards precisely so those numbers cannot
leak into anything.
Two checks cost seconds and are worth running freely:
./benchmark.sh --verify # is the ratio report in step with results.json?
./benchmark.sh --check # do the cases still match their published examples?
Adding or changing a case? benchmark/AUTHORING.md has the rules โ the first
being that a case must measure the same pipeline its published example shows,
which CI enforces.
๐ Acknowledgments
Great thanks to Indong Yoo, CTO of Marpple, the creator of FxTS (and FxJS before it), whose functional programming model โ lazy iteration with first-class, order-preserving concurrency โ this library ports to Dart. All core ideas, operator semantics, and the original test suite come from the marpple/FxTS repository.
๐ค Author
Bansook Nam
- ๐ Website: https://github.com/bansooknam
- ๐ Github: @bansooknam
๐ข Publishing the docs site
Normally there is nothing to do. docs/
is generated and untracked; .github/workflows/pages.yml builds and publishes
it on every push to main. A lib/ change does not require rebuilding the
bundle, re-stamping pages or committing anything.
The one part worth steering by hand is how many playground snippets get precompiled. Precompiling is what makes โถ Run take ~200 ms instead of ~2.5 s, because the page ships the compiled JS instead of calling the DartPad compile service in the browser. It is also the slow part of the build โ about a second per snippet, four at a time โ so the default only covers the first playground on each page.
Trigger a publish by hand
gh workflow run pages.yml # republish at the default scope
gh workflow run pages.yml -f pg_scope=all # โฆprecompiling every snippet
gh workflow run pages.yml -f pg_scope=none # โฆskipping precompilation entirely
gh run watch # follow it
or Actions โ pages โ Run workflow in the browser.
pg_scope |
Snippets precompiled | Cold build |
|---|---|---|
first (default) |
446 of 784 โ the first playground on each page | ~8 min |
all |
all 784 | ~13 min |
none |
none; every Run compiles over the network | seconds |
Artifacts are content-addressed and cached per scope, so a repeat run only
compiles what actually changed. A change to lib/ re-keys every snippet at
once โ that is inherent, since an artifact is keyed by the snippet plus the
library it was compiled against, and it is what stops a stale artifact
outliving the library.
A manual all run is temporary
A run publishes exactly the scope it was given. So pg_scope=all holds
only until the next push to main, which republishes at first and drops the
extra artifacts from the deployed site. Nothing breaks โ those pages just fall
back to the compile service and get slower on Run.
If you want all permanently, change the default in
.github/workflows/pages.yml rather than
re-running by hand:
env:
PG_SCOPE: ${{ github.event.inputs.pg_scope || 'first' }} # โ 'all'
Build it locally to look at it
None of these commit anything โ docs/ is ignored.
./run.sh # build the site and serve it (-o opens a browser)
./run.sh -s # serve what is already built, no rebuild
./deploy.sh # build exactly what CI builds, then stop
dart run tool/precompile_playgrounds.dart --status # coverage report, no network
dart run tool/rebuild_page.dart tutorials/map.html # one page's artifacts, for a fast local preview
๐ค Contributing
Contributions, issues and feature requests are welcome! Feel free to check the issues page.
CONTRIBUTING.md has the working rules: branch naming and
how to keep a long-running feature from becoming a merge event, what a PR
description has to answer, and the gates a branch passes before review โ
dart analyze, a 100%-passing dart test, the playground-bundle check that
catches wrapper drift, and the docs and translation checks โ all of which CI
also runs, so nothing silently depends on you having run them.
Performance claims need a paired A/B, not a sweep; the ~5% noise floor and the
instrument for seeing past it are documented there too.
๐ License
Copyright ยฉ 2023 Bansook Nam.
This project is MIT licensed.