fxdart 0.8.0
fxdart: ^0.8.0 copied to clipboard
A functional programming library for Dart, ported from FxTS. Lazy evaluation, concurrent async iteration, and pipeline-style composition.
0.8.0 #
Breaking — Fx is now an extension type #
Fx<T> was a class wrapping an Iterable<T>. It is now an extension type
over the same representation:
extension type Fx<T>(Iterable<T> _inner) implements Iterable<T> { … }
Every documented spelling is unchanged — fx(xs).filter(…).map(…).toList()
compiles and behaves exactly as before, and implements Iterable<T> keeps the
whole Dart iterable API available. What changes is that Fx no longer exists
at runtime: it erases to the iterable it wraps. So x is Fx<int> no longer
means anything (it is the underlying type), Fx cannot be extended or
implemented, and code that relied on the wrapper's runtime identity breaks.
Why it is worth a breaking change. As a class, Fx<T> carried T in its
runtime type arguments, so every operator constructed inside a chain method
was allocated with a runtime type argument and AOT could not specialize the
resulting iterator's type checks — the same effect 0.7.6 documented and fixed
for the async surface only. Measured on dropWhile().head() over 1,000,000
readings: 11.6 ms as a class, 5.3 ms as an extension type, against 5.0 ms
for the hand-written loop. Extension methods on a wrapper class measured
11.6 ms, so it is the wrapper object that costs, not the dispatch;
@pragma('vm:prefer-inline') on the chain methods made no difference at all.
Across the 53-case DartComparison suite: 13 cases faster, none slower. Headline verdicts move to 16 fxdart / 11 tie / 26 native, and the median fxdart/native time ratio goes 1.195 → 1.051. Highlights:
| case | before | after |
|---|---|---|
| first-over-limit | 2.37× | 1.05× (tie) |
| food-spending | 1.79× | 1.06× |
| date-window-spend | 1.38× | 0.74× (fxdart wins) |
| category-rank | 1.22× | 0.95× (fxdart wins) |
| smoothed-zone-changes | 1.20× | 0.94× (fxdart wins) |
| recent-errors | 2.50× | 1.61× |
A side effect worth noting: erasure means an fx(…) chain is the
underlying iterable at runtime, so the List-range protocol below sees straight
through it. The Fx.listRange member added for that purpose is gone —
unnecessary rather than removed.
FxAsync is unchanged; async chains are dominated by per-element futures, not
by this.
Performance — a List source stays a List through the lazy chain #
take, drop, takeRight and dropRight over a List are nothing
more than a contiguous range of that list, but every one of them still
wrapped the list's own iterator in another moveNext layer, and every
operator downstream pulled values through it. A new internal protocol
(lib/src/lazy/list_range.dart) lets an operator resolve its source to
a (list, start, end) triple once, when the iterator is created,
and then index the backing list directly. Ranges compose, so
drop(2, take(6, xs)) is one range rather than two wrapper layers, and
Fx reports its own range too — zip(fx(xs).drop(1)) is now exactly as
fast as zip(drop(1, xs)), instead of quietly losing the fast path to
the chain wrapper.
Three consumers take advantage of it:
zip/zip3resolve each side independently, so shifting one input withdropdoes not cost the other side its fast path.zipcarries indexed/pulled variants for both sides — the mixed shapes are worth their dispatch, measured.zip3specialises only the all-indexed case: its seven mixed shapes would add more targets to every downstreammoveNextthan they earn back.windowed/chunkskip the ring buffer over a list. Each window is already a contiguous slice, so it is filled straight from the backing list with no upstream iterator at all.
The trade-off is the one takeRight/dropRight already made in 0.7.3:
an indexed source mutated during iteration is not reported as a
ConcurrentModificationError, and a range's bounds are the ones the
list had when iteration started. Values, order, laziness and pull counts
are unchanged — zip still pulls its left side before its right, and
still never pulls the right side on the attempt that finds the left one
empty. test/lazy/list_range_test.dart runs every affected operator
twice, once over a List and once over a sync* source that cannot be
a range, and asserts the two agree.
Measured on consecutive-over-limit, the DartComparison case that builds
a three-hour sliding window out of zip + drop, against the same
hand-written index loop (Apple M1 Max, AOT, fresh processes per side):
| N | before | after | vs. the index loop |
|---|---|---|---|
| 1,000,000 | 78.8 ms | 23.5 ms | 4.79× → 1.36× |
| 10,000 | 764 µs | 206 µs | 6.70× → 1.77× (verdict native → tie) |
| 100 | 8.6 µs | 2.8 µs | 4.58× → 1.70× (tie either way) |
3.35× faster, of which ~2.0× is the fast paths and the rest is the
example moving onto zip3 — the nested-record repack was two thirds of
the remaining allocation. windowed(3) over a list is ~1.6× faster on
the same data.
Across the full 53-case suite the median fxdart time is unchanged
(1.006× of the previous release, i.e. inside the ~5% run-to-run noise
floor), with 35 of 159 measurements more than 5% faster. Every apparent
regression was re-measured individually on an idle machine and did not
reproduce; the one that looked most real, valid-emails, was settled
with a paired interleaved A/B against the previous library — 0.992×,
faster in 5 of 9 rounds — confirming the drift was on the native side of
that case, not in the chain.
Performance — async operators join the fast-pull protocol #
Three async operators (takeAsync, concatAsync, uniqByAsync) now
implement the internal FxFastIterator protocol, letting serial terminals
like toListAsync skip Future allocation on synchronous pulls. The
predicate in uniqByAsync uses a then/bare pattern instead of forced
async/await, answering sync callbacks directly.
concatAsyncrewrote from a forcedasynccallback to anextOr()implementation that checks upstream iterators forFxFastIteratorand calls theirnextOr()directly, falling back tonext()for non-fast sources.takeAsyncimplementsFxFastIteratorand delegates to upstream'snextOr()when available.uniqByAsyncchanged from(A a) async => seen.add(await f(a))toif (key is Future) return key.then(seen.add) else return seen.add(key), skipping the async wrapper for sync callbacks.
All three preserve Concurrent marker pass-through semantics by falling back to
a legacy unfused implementation when a concurrent marker arrives.
Measured on paged-feeds-dedupe (the worst async case in DartComparison,
N=100,000): 169.2 ms → 92.7 ms (45% faster), ratio 2.06× → 1.17× vs native.
The two/three sync pulls per page-fetch that previously allocated a Future each
now answer directly.
Performance — uniq gains a toList() fast path for List sources #
_UniqIterable and _UniqByIterable now bypass the generic Iterable.toList()
growth-reallocation path when their source is a List, building the result in
one pass with direct iteration.
Measured on first-visit-merchants (N=1,000,000): 52.2 ms → 40.7 ms (22%
faster), ratio 1.86× → 1.71× vs native. The pipeline's map → uniq →
toList() still pays the measured ~1.4× per-chain overhead for two operators,
but uniq's toList no longer adds allocation overhead.
Added — foldBy #
Folds the values under each key in one pass, without ever materializing the groups:
foldBy((Tx t) => t.category, 0.0, (sum, t) => sum + t.amount, txns);
// {Food: 812.40, Transport: 96.15, …}
groupBy followed by a fold per group builds a List for every key first —
allocation proportional to the input, for an answer proportional to the
number of keys. This is what the hand-written
totals[k] = (totals[k] ?? 0) + v loop does instead. Not an FxTS port; the
shape is Kotlin's groupingBy().fold(). Keys come out in first-seen order,
like groupBy. Sync + async + both chain methods.
groupBy remains the answer when you want the elements themselves — the new
operator is for when you only want the aggregate. The seed is a value
shared by every key, exactly as in fold, so a mutable seed would be shared
across keys; that is documented on the operator.
Measured in isolation over 1,000,000 transactions into 5 categories:
groupBy + per-group fold 50.2 ms → 20.5 ms (3.23× → 1.32× of the
hand-written loop). Four DartComparison examples moved onto it:
| case | before | after |
|---|---|---|
| invoice-summary | 2.29× | 1.06× |
| budget-alerts | 2.50× | 1.27× |
| monthly-ledger-report | 1.15× | 0.56× — fxdart wins |
| monthly-category-report | 2.46× | 2.17× |
It does not fit every grouping case, and that is worth stating: a mean needs
sum and count, and carrying both in a record accumulator allocates a record
per element — measured at 1.17× → 4.27× on top-category-average, which
therefore keeps groupBy.
Added — Fx.zip3 / FxAsync.zip3 #
zip3 existed as a top-level function but had no chain method, so a
three-way zip had to be written as zip(a).zip(b) plus a map to
unpack the nested record:
// before
fx(xs).zip(fx(xs).drop(1)).zip(fx(xs).drop(2))
.map((w) => (w.$1.$1, w.$1.$2, w.$2))
// now
fx(xs).zip3(drop(1, xs), drop(2, xs))
Which is also the shortest way to spell a three-element sliding window
when you want the elements as a record rather than a list — windowed(3)
remains the answer when a list is what you want.
Docs #
The consecutive-over-limit comparison page moves onto zip3: the
nested-record repacking map was ceremony forced by zip being binary,
and it is gone from the English, Korean and Spanish versions along with
the paragraph explaining it. A new paragraph says why the shifted inputs
cost nothing — drop(n) over a List is a range of it, and zip3
reads all three ranges by index.
Tests #
Line coverage stays at 100.00% (3938/3938, up from 3837).
0.7.9 #
Added — predicate combinators #
A unary predicate can now be built out of named pieces instead of nested
lambdas: isEven.and(isPositive), .or, .xor, .negate, and
.contramap<A>(f) to move a predicate onto another input type. They
compose anywhere a predicate is expected — filter, reject,
takeWhile, dropWhile, countWhere, partition.
This is not an FxTS port. TypeScript writes the same thing as &&
inside an arrow function and keeps its types through inference; in Dart
that costs a lambda and a repeated parameter name per combination, so
the operators earn their names. .negate is the extension-getter form
of the existing top-level negate — the two are the same function.
and and or short-circuit like &&/|| and call the right-hand
predicate only when the left one doesn't decide; xor always calls
both. Nothing runs until the composed predicate itself is called.
Added — Either.map2 … map5 #
Combining independent Eithers no longer has to go through an
accumulating scope: parseName(form).map2(parseAge(form), User.new)
keeps the leftmost Left and runs combine only when every branch
is a Right. Arities 2–5, capped where zipOrAccumulate2..5 and
Curry2..Curry5 are capped.
The two are not redundant. zipOrAccumulate reports an EitherNel with
every failure and needs an accumulate scope; mapN reports one
failure and works on plain values, which is what a fallible-lookup chain
wants. What's fail-fast in mapN is the reporting, not the work — the
branches are already-evaluated values, so all of them ran.
Added — Either.alt / Either.orElse #
The fallback ladder — "try this, and if it failed try that" — now has a name:
fromCache(key).alt(() => fromDisk(key)).alt(() => fromNetwork(key));
alt(other) discards the failure and takes the alternative lazily, so
nothing is looked up on the success path. orElse(f) hands the failure
to f and may change the failure type, for a fallback that depends on
what went wrong.
recover claimed to replace this whole family, and its doc has been
corrected. It replaces handleError/handleErrorWith — the cases where
the handler wants a raise scope rather than an Either built by hand.
It does not replace the plain case where you already have the
replacement Either, which is what these two are for.
Added — Either.filterOrElse #
Inline validation without a flatMap + if:
parseAge(s)
.filterOrElse((n) => n >= 0, (n) => 'age cannot be $n')
.filterOrElse((n) => n < 150, (n) => 'age $n is implausible');
A Right whose value fails the predicate becomes the Left that
onFalse builds from that value; a Left passes through and the
predicate never runs. It is the Either-value form of Raise.ensure,
which already did this inside an either { } builder — a test pins the
two to the same answer.
Added — mapValues / mapKeys / mapEntries #
The map half of the library could pick, omit, pickBy, omitBy,
evolve and compactObject, but not transform every entry:
mapValues((n) => n * 2, {'a': 1, 'b': 2}); // {a: 2, b: 4}
mapKeys((k) => k.toUpperCase(), scores);
mapEntries((e) => (e.$2, e.$1), byName); // invert
mapEntries takes the whole (key, value) record, the same shape
pickBy/omitBy/fromEntries already use, and generalises the other
two. mapKeys and mapEntries are last-one-wins on a collision, like a
map literal — documented, and pinned by a test.
No filter/filterWithKey went in: pickBy/omitBy already take the
whole record, so ignoring one half is how you filter by the other. Their
docs now say so with an example, which is what was actually missing.
Added — index-aware map / filter / flatMap / fold #
mapWithIndex, filterWithIndex, flatMapWithIndex and foldWithIndex
hand the element's 0-based position to the callback as a second argument,
sync and async, top-level and as Fx/FxAsync chain methods:
fx(rows).mapWithIndex((row, i) => '${i + 1}. $row').toList();
zipWithIndex already expressed all four, but through a record per
element and a callback body reading p.$1/p.$2. These allocate
nothing extra and read as the operation they are. Tests pin each one
against its zipWithIndex equivalent.
The index counts what reaches that stage's input, so a filter above
mapWithIndex renumbers, and filterWithIndex still advances its count
across elements it drops. Numbering follows source order even under
concurrent, which overlaps the upstream pulls but still resolves them
in order — pinned by a test at width 5 with reversed latencies. The
async operators keep their counter per iteration, not per iterable, so
re-iterating a chain restarts at 0.
Added — foldRight / foldRightWithIndex #
fold only went left to right, which is the wrong direction whenever
the combining step isn't associative:
foldRight(0, (acc, a) => a - acc, [1, 2, 3]); // 1 - (2 - (3 - 0)) == 2
fold(0, (acc, a) => acc - a, [1, 2, 3]); // ((0 - 1) - 2) - 3 == -6
The reducer keeps fold's (acc, element) argument order rather than
Haskell's foldr flip, so one callback works with either direction.
foldRightWithIndex reports each element's position in the source,
so the last element arrives first carrying the highest index — the same
number foldWithIndex and mapWithIndex give that element, pinned by a
test. fpdart renumbers its reversed walk 0, 1, 2 instead; agreeing with
the rest of the library seemed worth more than matching that.
Both are strict in a way fold isn't: walking backwards means knowing
where the end is, so a non-List source is materialized and
foldRightAsync drains the stream before it starts. Documented on each.
Added — takeWhileRight / dropWhileRight #
takeRight/dropRight could only count, so trimming a trailing run
meant knowing its length in advance:
dropWhileRight((c) => c == ' ', chars); // trim the trailing blanks
takeWhileRight((a) => a > 2, [1, 4, 2, 3, 4]); // (3, 4)
Both return source order, so they compose with everything else and
partition the source between them — a test pins that. fpdart's
takeWhileRight hands back the reversed run instead.
dropWhileRight streams: a matching run is held back only until some
element fails the predicate, which proves the run was not the suffix and
releases it, so memory is the longest run rather than the source. A test
pins that it emits after three pulls of a five-element source.
takeWhileRight cannot emit before the source ends, by definition, and
buffers the longest run it has seen.
A List source is indexed from the end by both, so the predicate is
called only on the trailing run, in reverse. That is a visible
difference for an impure predicate, and it is documented on each.
Docs #
Seven new FxDart 101 pages, in English and Korean, each with three
runnable demos: takeWhileRight and dropWhileRight in section 5,
…WithIndex in section 6, foldRight in section 7, mapValues in
section 9, predicate combinators in section 10, and Either
combinators in section 13.
Three of them cover a family rather than one function — …WithIndex
takes all four index-aware operators, mapValues takes mapKeys and
mapEntries too, and Either combinators covers map2…map5,
alt/orElse and filterOrElse. That follows the pages that were
already grouped this way (predicates, gt · gte · lt · lte,
delay & sleep): the lesson is the family, not the entry point.
tools/build_single_file.sh carries a hand-maintained list of _$name
wrappers for every top-level function fx.dart reaches through an
import prefix. Sixteen were added for the new operators — the bundle
build fails loudly when one is missing, which is how they were found.
Tests #
Line coverage stays at 100.00% (3837/3837, up from 3607).
0.7.8 #
Added — the events layer's second half #
fxEvents shipped in 0.7.3 with the operators a push chain cannot live
without: debounce, throttle, sampleOn, combineLatest,
withLatestFrom, switchMap, startWith, plus race/merge and
LiveValue. This fills in the rest of the rxdart surface that is
genuinely push-only — the things a pull pipeline has no way to express,
because it has no clock and no notion of several live sources at once.
Gating — stopOn(trigger) closes the chain and cancels both
subscriptions the first time trigger fires; startOn(trigger) drops
source events until it fires, then passes them for good. The names are
not Rx's takeUntil/skipUntil because Fx.takeUntil(predicate)
already means takeUntilInclusive on the pull side, and one name cannot
mean two things in one library. FxSubscriptions is the companion for
owner-driven teardown: a bag with add/addAll/cancelAll/pauseAll/
resumeAll, emptied before its cancellations are awaited so a second
cancelAll cannot cancel anything twice.
Higher-order mapping — mergeMap(f, {concurrent}) runs every inner
stream at once, optionally capped, with the extra source values queued;
concatMap(f) runs them strictly in order; exhaustMap(f) keeps the
first and ignores the rest, which is the double-submit guard. With the
existing switchMap that is all four policies for "an event arrived
while the last one is still running". mergeMap rather than flatMap,
since flatMap already means iterable-flattening.
Batching — chunk(count), chunkOn(trigger), chunkEvery(window).
The root word is the pull layer's chunk; …On takes a trigger stream
and …Every takes a clock. Both time-driven forms stay silent on an
empty window rather than emitting an empty list, and flush what is
buffered when the source closes.
Time shaping — delay(duration) shifts the whole stream with its
spacing intact; spaceBy(gap) is the lossless counterpart of throttle,
queueing a burst and releasing one event per gap; sample(period) is
sampleOn with the clock built in.
Multi-source — FxEvents.waitAll emits one list of every source's
last value once all have closed (Future.wait for streams);
FxEvents.zip/zipWith pair by index; FxEvents.combineLatestAll is
the N-ary combineLatest; FxEvents.concat/followedBy sequence;
mergeWith/raceWith are the instance forms of the existing statics.
Fan-out — share() lets many listeners consume one run of a chain.
Every operator here builds its own StreamController, so a chain is
single-subscription; share connects on the first listener and
broadcasts from there. It deliberately does not reconnect the way Rx's
share does — the upstream chain has no second run to give — so the
last listener leaving closes it for good. LiveValue.from(source) and
LiveValue.seededFrom(seed, source) build a hot LiveValue straight
from a stream; named constructors rather than an optional seed so a
nullable T can still be seeded with null.
Errors — onErrorReturn(value) substitutes per error and carries on,
since a Dart stream error does not end the subscription;
onErrorResume(f) cancels the source on the first error and switches to
a fallback stream for good; FxEvents.retry(factory, [count]) rebuilds
the stream instead of patching its errors, with the budget counting
re-subscriptions.
Docs #
Eight new FxDart 101 pages in section 14 (stopOn, mergeMap,
chunkOn, spaceBy, waitAll, onErrorResume, share,
fxSubscriptions), each with three runnable demos, in English and
Korean. Section 14 now runs to fifteen pages.
Tests #
Line coverage stays at 100.00% (3607/3607, up from 3264).
0.7.7 #
Added — tee / tee3 #
Several folds over a single pass of a source, with no buffering.
fork can already feed two readers from one pass, but only by buffering:
its cursors advance independently, so draining one before the other holds
every element the lagging cursor has not reached. Expressing the readers as
folds — a seed and a step — lets both advance on the same element, so
there is never a value one has seen and the other has not, and nothing to
remember. The counterpart of Rx's publish(), where attaching both
subscribers before connect() is what avoids the buffer.
The two accumulators are independent and need not share a type; tee3
takes three; teeAsync is the async form. Chain methods on Fx and
FxAsync. The trade is deliberate: tee feeds folds, not pipelines — for
two genuinely independent readers, fork and its buffer remain the answer.
On the RxDartComparison's tee-the-pipeline, measured paired and
interleaved: time 36.6 → 17.8 ms (-51%), peak RSS 57.6 → 22.6 MB
(-61%). That was the section's largest memory loss; fxdart now holds less
than RxDart's 25.4 MB. The example and its benchmark were moved onto
tee, matching a multicast primitive against a multicast primitive
rather than a general buffering one.
Performance — the fused stage list is compiled into a link chain #
The four per-element loops (_mapFrom, _applyFrom, fxStreamDrive,
fxFusedDrive) walked a List<FxStage> by index, and in the drives that
list was a captured local — so stages.length and stages[i] each went
through the closure context object, per stage per element. The list is now
compiled once per FxFusedAsyncIterable into a chain of FxLink nodes,
each holding its successor: the loops walk pointers, and an asynchronous
stage resumes at link.next instead of re-deriving i + 1.
Internal only — no signature, laziness, ordering or error-behaviour change.
stream-into-pipeline, the RxDartComparison's largest remaining deficit,
goes from 1039 µs against RxDart's 783 µs (1.32×) to 778 vs 766
(1.02×) — parity. Across 30 async cases the effect is otherwise small
(median -0.49%, mean -1.24%, 5 cases ≥2% faster, 1 ≥2% slower).
Two changes measured alongside these were reverted for failing to pay:
collapsing an adjacent filter+map into one fused stage (+0.16% median,
-0.8% on its own target case), and an onErrorResume operator, which made
resume-with-cache 151% slower — routing the cached tail through the
async chain costs more than the await for it replaced.
Docs #
101 section 6 gains tee and tee3 (between fork and ifEmpty), both
translated to Korean. The tee page carries the name's etymology — the
letter T, after the plumbing T-splitter Unix borrowed for its own tee —
and is explicit that Python's itertools.tee, which splits one iterable
into independent iterators, is the operation FxDart spells fork, not
tee. FxDart's tee branches the consumption one level further
downstream.
Tests #
Library line coverage back to 100% (3264/3264), the 0.7.4 standard
that 0.7.6's fused drive had left at 98.47%. test/strict/tee_test.dart
and test/lazy/fused_link_test.dart cover the new operator and the
compiled chain's asynchronous branches — including the pull path (reached
by wrapping a run in take, which denies the terminal its push drive) and
the drive's synchronous-throw paths, reachable only when a run's source is
itself a run.
0.7.6 #
Performance — records stopped costing 2× in async pipelines #
dependent-calls-in-sequence was the RxDartComparison's worst async loss —
1.29× behind RxDart. Nothing about the pipeline explained it —
the same chain with a String accumulator ran at hand-written-loop speed.
The accumulator's type was the whole gap: swapping the tuple
(String, String) for a two-field class made it disappear, and giving
RxDart's asyncMap the same tuple made RxDart the slower side (57 ms).
The cause is not records themselves; it is where the operator's iterator gets
allocated. Dart AOT specializes an operator's type checks only when the type
arguments are statically known at the allocation site. Built through a chain
of un-inlined generic factories — FxAsync.scan → scanAsync →
DelegateAsyncIterable(() => …) — the type arguments are runtime values, so
every x is Future<B> and x as B in the hot loop becomes a real subtype
test. Cheap for an ordinary class; ~1 µs per element when B is a record
type. fxdart hands records out everywhere: zip, attach, pairwise,
zipWithIndex, and any tuple scan accumulator.
Three fixes, all internal — no signature moves, and next() still returns a
Future, so a hand-driven .iterator.next() loop is unaffected:
vm:prefer-inlineacross the async operator surface. Every*Asyncfactory and every one-lineFxAsyncchain method now inlines back into the caller, so the iterator is allocated where the type arguments are constants and the loop's type checks fold away. The chain is only as strong as its weakest link — one un-inlined generic hop loses the specialization for the whole operator — which is why this is applied across the surface rather than case by case. Measured on record-carrying async pipelines:scan2.26×,attach2.49×.scanis a fusable stage.scanAsyncis one-in-one-out likemap, only stateful, so it now joins themap/filter/takeWhilestage run instead of layering a pull on top of it.scan(…).map(…)is one iterator, not two — one future and one microtask hop per element saved. At most one scan per run (the accumulator has one slot); a second scan starts a new run. The seed is emitted through the stages that follow the scan, as it always was. AConcurrentmarker still falls back to the unfused layering.fxFusedDrive, the third push terminal. BesidefxStreamDrive(0.7.4) andfxPoolDrive(0.7.5): when an all-consuming serial terminal (toListAsync/eachAsync/foldAsync) owns a fused stage run, the stages execute inside the stage future's own callback and hand the value straight to the terminal — the pull's second future and itsIterResultwrapper are gone. A plain-Iterablesource (toAsync) is stepped withmoveNext()directly, so it allocates nothing per element either. 2.0 → 1.0 microtasks per element.
Together, per element (AOT, 200,000 elements, the example's per-call
Future.delayed replaced by a microtask so what is measured is the pipeline
and not the platform timer):
| before | after | |
|---|---|---|
scan(tuple).map(…).toList() |
1966 ns | 887 ns |
map(async).toList() |
1114 ns | 863 ns |
attach(async).toList() |
2210 ns | 888 ns |
dependent-calls-in-sequence, measured before and after on the same machine
in the same session, goes from 47.4 ms against RxDart's 36.7 ms (a loss)
to 34.9 ms against 36.8 ms (a win) — 1.36× faster than before, and the
last async case where the pull model was visibly behind a push Stream for
ordinary sequential work.
Across the whole RxDartComparison suite at the headline scale, the speed
tally moves from 31 FxDart / 7 tie / 3 RxDart to 34 / 7 / 0 — RxDart
no longer wins a case. latency-extremes and pipeline-into-stream move
from RxDart wins to ties; bound-the-stall and price-or-fallback move from
ties to FxDart wins. Nothing moves the other way. In the DartComparison
suite, where the same async machinery is measured against hand-written Dart,
price-lookup-fallback (1.35× → 1.01×) and sequential-configs (1.12× →
1.01×) move from native wins to ties, taking that tally from 41 native /
3 tie / 9 FxDart to 39 / 5 / 9.
Tests #
test/lazy/fused_scan_test.dart pins the combinations the fusion creates:
scan before and after map/filter/takeWhile, the seed flowing through the
later stages (and being dropped or ending the run there), two scans in a
chain, a Future seed, an empty source, sync and async accumulator errors,
a Future element in the source, a throwing source, the concurrent
fallback to the unfused layering, a stream source keeping the pull path, and
each / fold / an async emit seeing exactly what toList sees.
0.7.5 #
Fixed — concurrentPool went quadratic on a fast source #
concurrentPoolAsync refills its pool on every completion instead of on
demand — the 0.1.1 "eagerly keep the pool full" behavior, which is what lets
a one-pull-at-a-time terminal like toList still overlap n requests. The
consequence went unnoticed: when the source resolves faster than the
consumer drains, the ready-results buffer runs ahead without bound, and that
buffer was a growable List dequeued with removeAt(0) — O(length) per
element. The pipeline as a whole was O(n²).
It takes an effectively-instant source to show — cached lookups,
Future.value, futures that are already complete. Any real per-element
latency holds the buffer at the pool size, which is why the benchmark suite
never caught it. Both internal buffers (settled results waiting for a
consumer, and consumer pulls waiting for a result) are now Queues, O(1) at
each end. Measured AOT over an all-resolved source with a pool of 3:
| N | before | after |
|---|---|---|
| 2,000 | 6.5 ms | 1.8 ms |
| 4,000 | 22.0 ms | 5.5 ms |
| 8,000 | 84.8 ms | 7.0 ms |
| 16,000 | 724.1 ms | 12.9 ms |
Completion ordering, laziness, the eager refill itself, and error propagation are unchanged, and the async benchmark suite is unmoved — with real latency the buffers never grow, so there was nothing there to win.
Added — FxDart.config #
A namespace for process-wide settings, holding one switch so far:
FxDart.config.optimizeMemoryForConcurrentPool(defaultfalse) — putsconcurrentPoolAsyncback on theListbuffers described above. The trade it names is smaller than it sounds: aQueuekeeps a power-of-two backing store and so can hold up to ~2× the elements' worth of slots, but on thecompletion-order-poolbenchmark the two forms measured the same peak RSS to within 0.1 MB. It is there as an escape hatch — to reproduce a measurement taken against an earlier version, or to pin the old behavior if some workload turns out to prefer it.
Settings are read when a pipeline starts iterating, not when it is built, so flipping one affects pipelines started afterwards and leaves an already-running iteration on the behavior it began with.
Performance — push execution reaches the pool and the stream bridge #
0.7.4 gave stream-sourced chains a subscription execution model; the two
places that still round-tripped every element through a Completer or an
async generator now follow it. Both are internal — no signature moves, and
next() still returns a Future, so a hand-driven .iterator.next() loop
is unaffected.
concurrentPooldrained by push.concurrentPoolAsyncreturns a marker iterable, andfxPoolDrivesits besidefxStreamDriveintoListAsync/eachAsync/foldAsync: when an all-consuming terminal owns the pool, each element is emitted from inside the pull's own continuation instead of crossing a per-elementCompleter. Completion ordering, eager refill, error position, and the slow-consumer buffer are unchanged; a pool used mid-chain keeps the pull path. 2.0 → 1.0 microtasks per element, andcompletion-order-poolgoes from 1.07× behind RxDart to parity — the section's speed tally moves from 31 FxDart / 6 tie / 4 RxDart to 31 / 7 / 3.toStream()withoutasync*. It was the lastasync*inlib/, and everyyieldcrossed_AsyncStarStreamController. It is now a hand-writtensynccontroller, everyaddissued from a future continuation or a controller callback. The generator's lock-step is preserved exactly — one element produced per element consumed, a paused subscription stops pulling,breakin anawait forstops production for good, nothing pulled before the stream is listened to.toStream()now adds 0.0 microtasks per element.
The second one did not move its benchmark, and the reason is worth
recording: pipeline-into-stream stays at ~1.06× because a stage-by-stage
count shows chunk and toStream each cost nothing per element and all
3.0 of that chain's microtasks belong to mapConcurrent — that is,
concurrentAsync's ordered batching. That is the next target, and a larger
one, since mapConcurrent is the most repeated async idiom in the
comparison suite.
Tests #
test/util/config_test.dart runs concurrentPool's completion ordering,
full drain of a fast source, and error propagation against both buffer
implementations, and guards the fix directly: quadrupling N must cost less
than 8× the time, which the List form fails at ~16×.
test/stream/to_stream_test.dart covers the two rewrites: the stream
bridge's lock-step, pause/resume, cancel, lazy start, error delivery and
single-subscription contract, and the pool terminals' element coverage,
completion order under a slow consumer, and error propagation — including
that the manual pull path still works while the push drive exists.
0.7.4 #
Performance — the async pull machinery #
An investigation of the RxDartComparison benchmarks RxDart was winning found
no algorithmic problem: all of them are async cases, and the entire gap was
per-element overhead in the pull protocol — microtask hops and future
allocations that a push Stream's synchronous event dispatch never pays.
Three design fixes, identical API, laziness, ordering, and error behavior:
- Wasted
awaits on synchronous callback results. Every async operator awaited its callback'sFutureOrresult; in Dart, awaiting an already-synchronous value still schedules a microtask — one wasted hop per element per operator layer. Hot serial paths are now then-based with bare returns and anis Futureguard, so synchronous results complete the pull directly (measured 1.4× on a sync-callback map, 1.8× on three stacked maps):mapAsync,filterAsync,takeWhileAsync,dropWhileAsync/dropUntilAsync,scanAsync/scan1Async,flatMapAsync,ifEmptyAsync, and the terminalseachAsync/foldAsync/reduceAsync(sosumAsync,minAsync,countAsync, … inherit it). SerialAsyncIteratoridle fast path. The overlap serializer chained every pull off the previous pull's future even when nothing overlaps — the common serial consumer paid a hop per element for a guarantee it never used. An idle pull now enters the state machine directly; the chain only forms while a pull is actually in flight.fromStreamis a direct subscription bridge. The old bridge stackedStreamIterator+ the serializer + an async closure (~3 future layers per element). The new one listens once, completes the waiting pull straight fromonData, and pauses whenever no pull is waiting — same contract (lazy subscribe on first pull, backpressure via pause, an error answers the pull that met it and ends the iteration), 2.1× on a drain.usingAsynccaches its resolved iterator instead of re-chaining through the acquire future on every pull.
Then three structural changes, all internal — the public protocol, operator signatures, laziness, ordering, and error behavior are unchanged:
- Stage fusion. A run of
map/filter/takeWhileno longer stacks one iterator (and one future) per operator: the run collapses into a single fused pipeline that applies every stage inline on each pulled element. When aConcurrentmarker arrives on a fresh iterator, the whole iteration is handed to the original unfused layering, soconcurrent(n)behaves exactly as before. - An internal fast-pull path. Iterators that can answer a pull
synchronously now do (
FutureOr, library-internal — the publicnext()still returns aFuture), and the serial terminals (toListAsync,eachAsync,foldAsync,reduceAsync,toStream) loop on it. A fused chain over a synchronous source runs with no per-element futures at all.flatMapAsync,scanAsync,usingAsync,timeoutAsyncand the stream bridge all participate;timeoutAsyncadditionally skips arming a timer for a pull that answered synchronously. - Subscription execution for stream-sourced chains. When an
all-consuming terminal sits on a chain whose source is a plain
Stream, the chain now runs by subscription — stages execute inonData, an asynchronous stage pauses the subscription (theasyncMapdiscipline), a failingtakeWhilecancels it — instead of pulling element by element. This is the push execution model applied under an unchanged pull API, and it is observably identical for a terminal that consumes everything. concurrentAsyncfills its batch with one continuation per pull instead ofsettleAll'sFuture.waitplus two wrapper futures per element.
Measured effects (AOT, N=10,000, RxDartComparison). Of the ten cases RxDart
led in 0.7.2, six now tie or win and none regressed:
stream-into-pipeline 12.63× → 1.34× (tie), crawl-the-pages 2.94× →
0.96× (tie), bound-the-stall 1.31× → 1.01× (tie), cursor-lifetime
1.14× → 1.01× (tie), price-or-fallback 1.07× → 1.00× (tie),
per-row-retry 1.08× → 0.93× (FxDart wins). The RxDart-faster count
across the section drops from 10 to 4 (31 FxDart / 6 tie / 4 RxDart).
DartComparison's async cases improved as well: stream-windowed-alerts
3.13× → 1.63× behind native, paged-feeds-dedupe 2.17× → 2.02×,
rate-limited-import 2.10× → 1.80×, concurrent-enrichment 1.36× → 1.24×.
The four still behind — dependent-calls-in-sequence (1.27×),
latency-extremes (1.11×), completion-order-pool (1.08×),
pipeline-into-stream (1.06×) — are dominated by genuinely asynchronous
per-element work (a real await per step, a completion-order pool, an
outbound Stream controller), where no amount of protocol trimming helps:
what is left is one future per element, which is what a pull protocol
fundamentally is.
Tests #
Library line coverage is now 100% (2999/2999). A new
test/async_fast_paths_test.dart covers the machinery above as white-box
behavior: fused stages and their effect order, the Concurrent fallback on
every fused operator, subscription-drive error/cancel paths, the stream
bridge's buffering and error handling, and each operator's legacy path.
0.7.3 #
Performance #
First pass: AOT-measured, over the operators the 0.7.2 additions and the DartComparison benchmark #53 (smoothed-zone-changes) exercise; that case's headline-scale gap vs hand-written Dart shrank from 2.7× to 1.37× with no API or output change.
uniqAdjacentBy/pairwise/ifEmpty— rewritten fromsync*generators to hand-written iterator classes (the 0.7.2 additions had missed the 0.7.1-era conversion;sync*moveNextis ~4× slower under AOT and compounds per chained operator).windowed/chunk— the shared sliding core keeps overlap in a reused ring buffer, so each emitted window costs exactly one exact-size allocation instead ofsublist+ growableadd. Windows are now fixed-length lists (mutating a yielded window withaddno longer works; contents and laziness are unchanged).sum/average— indexed fast paths forList<double>/List<int>(no iterator, unboxed loads; results bit-identical to the generic path).sumBy/averageByiterate lists by index.Fx.sum/average/min/maxnow unwrap the chain's inner iterable instead of re-iterating through theFxwrapper, andfx()/Fx.average/Fx.sumcarry@pragma('vm:prefer-inline')so AOT escape analysis can erase the wrapper allocation in per-element uses like.map((w) => fx(w).average()).
Second pass: the same AOT treatment extended to the rest of the sync surface. DartComparison #7 (top-log-level) now beats its hand-written Dart baseline outright; every benchmark checksum is unchanged.
takeRight/dropRight/reverse/cycle/flat/fork/using/split/transpose/entries— the remainingsync*generators rewritten as hand-written iterators (2.7–8.3× measured per operator;splitalso accumulates into aStringBuffer).differenceBy/intersectionByfuse their filter-then-uniqpair into a single pass over the second iterable.- List sources are indexed directly in
reverse/takeRight/dropRight— no snapshot copy (reverseof a 1M-element list 12.8×;takeRight(1000)of it ~1500×, since the untaken 999,000 elements are never copied). Non-List sources use O(length) ring buffers, anddropRightstreams through a delay line instead of materializing:take(2, dropRight(2, xs))pulls exactly 4 elements, and unbounded sources now work. Two visible edges: a negativelengthnow throws at the call site instead of at the first pull, and mutating a source list mid-iteration is no longer masked by an internal copy. find/findIndex— direct loops instead ofhead(filter(…))/zipWithIndex(8.7× / 3.5×: no per-element filter layer or index record).last/nthare O(1) on lists,sizeon lists and sets.min/max— indexed fast paths forList<double>/List<int>(10.2× / 8.3×) and a direct loop otherwise (1.8×); empty/NaN/tie results identical to the fold they replace.groupBy/countBy/uniq— per-element closure allocations removed (putIfAbsent,Map.update's two closures,uniqBy's identity key): 1.5× / 2.2× / 1.7×.map/scan/scan1—.toList()fills a pre-sized list when the source is aList(1.9× / 2.1× / 2.4×); the callback still runs exactly once per element, in order. The RxDartComparisonrunning-balance-feedcase (scan1(…).toList()end to end) got 2.08× faster, widening fxdart's win there to ~10×.Fxdelegateslength/isEmpty/isNotEmpty/first/last/single/elementAt/containsto the wrapped iterable —fx(list).lengthis O(1) instead of an iterator walk.
Added — the events layer (FxEvents) #
fxdart's push side: a zero-dependency chain over plain Dart Streams for
the problems that are genuinely events over time — the jobs the
RxDartComparison section's Part 4 used to concede. The pull core is
untouched; this is a separate module (lib/src/stream/events.dart) that
absorbs the Rx approach where the Rx approach is right, in fxdart style.
fxEvents(stream)→FxEvents<T>— a wrapper chain (deliberately NOTStreamextensions, so it can never collide with rxdart or any other stream library in the same file).- Time operators:
debounce(window)(trailing, flush-on-close),throttle(window, {leading, trailing}),sampleOn(trigger). - Combination:
combineLatest(other, combine),withLatestFrom(other, combine),switchMap(f)(cancellation-by-newer),FxEvents.race(candidates)(losers cancelled),FxEvents.merge(sources),startWith(value), plusmap/where/asyncMappassthroughs. LiveValue<T>— theBehaviorSubjectcounterpart reduced to its defining behavior: a current value whose late subscribers get the latest value first, then the live updates (.seeded,.value,.hasValue,.live,.close).- Bridges:
.pull()crosses an event chain into the typedFxAsyncpull world (fromStreamunder the hood);.streamunwraps for anyStreamAPI;FxAsync.toStream()remains the other direction.
The RxDartComparison examples #40–47 are rewritten on this layer — the push-side verdicts that used to read "RxDart's turf" are now honest ties. RxDart still has the far larger operator surface; what closed is the model gap, not the catalog.
0.7.2 #
Added — Rx-inspired pull operators #
Operators extracted from an internal comparison against RxDart
(plans/RXDART_COMPARISON_AND_SUGGESTION_PLAN.md): the Rx ideas that are
genuinely pull-shaped and time-free, re-designed for the demand-driven
model. None of these exist in FxTS; each doc comment says so and names the
Rx counterpart. Push/temporal operators (combineLatest, switchMap,
Subjects, time-windowed debounce/buffer/sample, …) remain explicitly
out of scope — bridge to Stream/rxdart via toStream()/fromStream for
those. Each addition ships sync + async + Fx/FxAsync chain forms,
tests, and a 101 tutorial.
Windowing (one shared sliding core; chunk was refactored onto it with
byte-identical behavior):
windowed(size, {step, partial})— sliding windows, the generalization ofchunk(chunk≡step: size, partial: true). Kotlin's naming; RxDart'sbufferCount(size, startEvery).pairwise()— adjacent(previous, current)record pairs, the window-of-2 special case that deltas/streak examples kept hand-rolling.
Filtering:
uniqAdjacent()/uniqAdjacentBy(key)— drops only adjacent duplicates (RxdistinctUntilChanged, DartStream.distinct), so no seen-set accumulates; complements the globaluniq/uniqBy.ifEmpty(fallback)/defaultIfEmpty(value)— lazily switches to a fallback iterable / single default when the source turns out empty (RxswitchIfEmpty/defaultIfEmpty).
Effects (new lib/src/lazy/effect.dart):
retry(attempts, f, {delay})— runs an effect until it succeeds, with a per-failure backoff hook; rethrows the last error with its original stack trace. Whole-pipeline retry is its terminal form:retry(3, () => fxAsync(...).toList()).mapRetry(attempts, f, {delay})— the per-element form, built onmapAsync, so it is parallel-safe: underconcurrent(n)each in-flight element retries independently while order is preserved.timeout(limit)— fails a pull that takes longer thanlimitwith aTimeoutException. Pull-model semantics: the limit is demand-to-item time per pull, not inter-event gaps (documented difference from Rx).using(acquire, use, release)/usingAsync— scopes a resource to one lazy iteration;releaseruns exactly once on completion or error. Abandoning iteration mid-way skipsrelease(a pull-model limit the docs call out; bound withtakeinstead ofbreak).
0.7.1 #
Added — pre-combined operators #
Convenience operators that collapse the multi-operator idioms observed
across the DartComparison examples and the daily_ledger typed-error rounds.
Each ships sync + async + Fx/FxAsync chain forms, tests, and a 101
tutorial. All are composition over existing operators — laziness, effect
order, and parallel-safety are inherited, not re-implemented.
Pipeline:
mapConcurrent(n, f)—toAsync().map(f).concurrent(n)as one step, on both sync and async sources. The single most repeated async idiom in the comparison suite (6 of the 11 hardest examples).groupedBy(key)— groups as chainable(key:, items:)named records in first-seen key order; per-group aggregation continues in the same chain instead of re-entering throughMap.entries.sortByDesc(key)— descendingsortByfor any comparable key (dates and strings have no-keynegation), sharingsortBy's extract-once machinery and unboxed fast paths.countWhere(pred)—filter+sizefused into one walk.attach(f)— lazily pairs each value withf(value)so the input stays beside its (possibly async) result; the async form is parallel-safe and composes withconcurrent.- Chain methods for the set ops —
differenceBy/difference/intersectionBy/intersectiononFxandFxAsync(the receiver is the free function's source argument), so example 40's triple chain-break disappears.
Typed errors:
flattenOrAccumulate(port of Arrow 2.x's name) — collects every success or EVERY failure from an existing collection ofEithers; the fail-slow twin ofsequence, completing the terminal trio withseparated. Top-level + async + chain terminals.eitherCatching/eitherCatchingAsync—eitherwith an exception boundary: thrown exceptions map into the typed error viaonThrow; the raise signal is never handed to it. Replaces the 4-layereither(catching(...))envelope.RaiseOps.recovergainedonThrow:— Arrow 2.x's three-clauserecover(block, recover, catch), non-breaking.Accumulator.dependent(block)— runs only when no branch has failed, making siblingAccumulated.valuereads safe by construction; names the manualif (!acc.hasErrors)guard that dependent-field validation always needed. (Dart-native addition; no Arrow counterpart.)Iterable.toNelOrNull()(port of Arrow'stoNonEmptyListOrNull) — any iterable →Nel?without the.toList()shuffle.- Async
Eitherextracts —rightsAsync/leftsAsync/separateEitherAsyncandFxAsync<Either>.rights()/.lefts()/.separated(), giving the async chain the same extract family the sync chain already had.
Changed #
lib/fxdart.dartnow exportssrc/typed/fx_either.dartthrough an explicitshowlist (it was the one unfiltered typed export).
Performance #
- Lazy sync operators rewritten from
sync*generators to dedicated iterator classes (map,filter,peek,flatMap,scan,scan1,compact,uniqBy,take,drop,takeWhile,dropWhile,dropUntil,takeUntilInclusive,slice,chunk,zip,zip3,zipWithIndex,range,repeat,concat,append,prepend). Async*moveNextcosts ~4× more than a plain iterator class under AOT and the penalty compounds per chained operator. Laziness, effect order, and per-iteration state are unchanged; the whole test suite passes as-is. sortByextracts each key exactly once (decorate–sort–undecorate; previously the key extractor ran twice per comparison) and uses unboxed fast paths when every key isdouble,int, orString.compareTosemantics (NaN,-0.0) are identical on every path; ordering is unchanged.maxBy/minBycache the running best's key — the key extractor now runs exactly once per element.sum/sumBy/averageaccumulate unboxed, switching from int to double accumulation at the first double value — bit-identical results to the previous boxednumfold on every input sequence.
Measured on the DartComparison benchmark suite (benchmark/, Apple M1 Max,
AOT): median fxdart/native time ratio improved from ~1.8× to ~1.5×, the
worst case from 18.4× to 5.9×, and four cases (top-expenses,
top-merchants, unique-tags, price-drop-detection) are now faster than
the hand-written native implementation.
0.7.0 #
Added — Dart 3.10 dot shorthands for Either #
Either.left/Either.right—constredirecting factories on the sealed base type. They exist so dot shorthands resolve: wherever the context type isEither, you can now writereturn .left(err)/return .right(value)— in return positions, switch-expression arms, and==comparisons (result == .right(3)).
Changed #
- SDK floor raised from
>=3.3.0to>=3.10.4(dot shorthands; also null-aware collection elements, repeatable_wildcards, digit separators). Requires a toolchain from Nov 2025 or later. compactObjectinternals: the explicit null check +as Vcast is now a single null-aware map element (e.key: ?e.value). Behavior unchanged.
0.6.2 #
Docs #
- Version-agnostic wording across README, the docs site, and the agent skills: feature descriptions no longer name the release that introduced them (versions live here in the CHANGELOG).
0.6.0 #
Added — typed errors (the Kotlin Arrow 2.x approach, ported) #
-
Raise<E>+ builders (either,eitherAsync,nullable,nullableAsync,foldRaise,foldRaiseAsync): write straight-line Dart inside a scope that can short-circuit with a typed error;Eitherappears only at the boundary. NoTaskEither/IOwrapper tower — Dart's ownFuture/throwis the effect system, exactly as Arrow uses Kotlin's. Foreign-scope signals rethrow (nesting is safe), leaked scopes throw a descriptiveRaiseLeakedError, and the signal is anErrorsoon Exceptionnever swallows it. -
Scope vocabulary (
RaiseOps):bind,bindAll,ensure,ensureNotNull(null-promoting),recover,withError. -
catching/catchingAsyncandEither.catching/Either.catchingWith— exception boundaries that always rethrow the raise signal first. -
Either<L, R>(sealedLeft/Right, exhaustiveswitch), with a curated Arrow 2.x method set:fold,map,mapLeft,flatMap,swap,getOrNull,getOrElse,onLeft/onRight,recover,toEitherNel. -
NonEmptyList<T>/Nel<T>— zero-cost extension type (needs SDK ≥ 3.3, hence the floor bump), the error carrier for accumulation. -
Error accumulation (the Arrow replacement for
Validated):r.accumulatewith lazily-detonatingAccumulateds,r.mapOrAccumulate,r.zipOrAccumulate2..5,r.bindNel. -
Pipeline integration:
rights,lefts,separateEither,sequenceEither(Async),mapOrAccumulate(Async)as top-level ops and asFx/FxAsyncchain terminals — fail-slow concurrent validation rides the existingconcurrent(n)back-channel. -
New agent skill (
skills/fxdart-typed-errors/) teaching AI coding assistants the typed-error system — when to reach foreitherblocks vs plain Dart, accumulation recipes, fpdart migration mappings, and the safety pitfalls. The existingfxdart-pipelinesskill cross-references it.
Changed #
- SDK floor raised from
>=3.0.0to>=3.3.0(extension types).
0.5.4 #
Docs #
- README: new CTA badge linking to the Dart vs FxDart comparison site — 50 side-by-side native-Dart vs fxdart examples with an honest verdict on each.
0.5.3 #
Added #
- AI agent skill (
skills/fxdart-pipelines/) following the Agent Skills spec — teaches coding assistants when to reach for fxdart (collections, bounded-concurrency Futures, Streams, complex flow logic) and the patterns/pitfalls that matter. Compatible with the communityskillsCLI (skills get fxdart). dart run fxdart:install_skills(alsofxdart_skillsviadart pub global activate fxdart) — zero-dependency installer that copies the bundled skills into Claude Code, Codex, Devin, Antigravity, OpenCode, pi, or generic.agents/skills/directories, project-local or--global, with--listand--remove.
0.5.2 #
Docs #
- Documented the full public API — every
fx()/ async chain method, the Dart-idiomatic aliases, the async iterator protocol types (Concurrent,IterResult, …), and the.curried/.uncurriedextensions now carry dartdoc comments. Coverage went from 65.7 % to ~100 % of the exported API.
Packaging #
- Moved the runnable Dart example to
example/fxdart_example.dartso pub.dev recognises it (was nested underexample/dart_example/).
0.5.1 #
Docs #
- List the by-key aggregates (
sumBy,averageBy,minBy,maxBy) in the README operator table.
0.5.0 #
Added #
averageBy(+averageByAsync, and.averageBy()on thefx()and async chains). The mean of a key over every element — one walk tracking a running total and count. Empty input returnsNaN(theaveragecontract). Completes the by-key family (sumBy/maxBy/minBy).
0.4.0 #
Added #
sumBy(+sumByAsync, and.sumBy()on thefx()and async chains). Sums a key of every element —map+sumin one terminal, so "total this field" is one call. Empty input returns0(thesumcontract); the async variant awaits the key extractor per element. Dart-native addition in themaxBy/minByfamily (Kotlin'ssumOf).
0.3.0 #
Added #
maxBy/minBy(+maxByAsync/minByAsync, and.maxBy()/.minBy()on thefx()and async chains). Returns the element with the largest/smallest key in one O(n) walk — the answer to thesortBy(key).head()anti-pattern, which sorts the whole pipeline to read one value. Keys compare likesortBy(Comparable.compare), ties keep the first element encountered, empty input returnsnull(likehead/last). Dart-native addition — FxTS ships only the numericmin/max; the name follows Kotlin'smaxByOrNullshape.
0.2.2 #
Renamed for Dart idiom #
toArray→toList,toArrayAsync→toListAsync(breaking). Dart has no "array" type — these have always returned aList, so they now carry the Dart-standard name. Applies to the top-level functions and thefx()/ async chain terminals. The old names are removed outright (not aliased): replacetoArray()→toList()andtoArrayAsync()→toListAsync().
Dart-idiomatic aliases added (both spellings supported) #
Every FxTS operator whose Dart Iterable/collection counterpart has a different
established name now exposes both names — the FxTS name for parity and the
Dart-idiomatic name as a first-class alias. Nothing is removed (that was
toArray's special case above); existing code keeps working, and the FxDart 101
course teaches the Dart-idiomatic spelling. Aliases exist at every level: the
top-level functions (+ their *Async twins), the fx() chain, and the async
FxAsync chain. On the sync chain several Dart names come for free because
Fx extends Iterable (Dart 3): firstOrNull, lastOrNull, elementAtOrNull,
any, forEach, length, indexed, nonNulls, contains.
Type-name aliases — the FxTS name claims a type Dart doesn't have:
| FxTS name | Dart-idiomatic alias |
|---|---|
unicodeToArray |
unicodeToList |
isBoolean |
isBool |
isNumber |
isNum |
isDate |
isDateTime |
Standard-library vocabulary aliases:
| FxTS name | Dart-idiomatic alias |
|---|---|
head |
firstOrNull |
last |
lastOrNull |
nth |
elementAtOrNull |
find |
firstWhereOrNull |
findIndex |
indexWhere |
some |
any |
size |
count (or .length on the chain) |
each |
forEach |
filter |
where |
reject |
whereNot |
flatMap |
expand |
flat |
flattened |
drop / dropWhile |
skip / skipWhile |
uniq / uniqBy |
distinct / distinctBy |
zipWithIndex |
indexed |
compact |
nonNulls |
toSorted |
sorted |
takeRight |
takeLast |
includes keeps only its FxTS spelling at the top level — a top-level contains
would collide with package:test's matcher; use the inherited .contains() on
the chain for the Dart idiom. See test/dart_aliases_test.dart for the
both-spellings contract.
Left as-is — already Dart-idiomatic or intentionally FP with no stdlib
counterpart: map, take, takeWhile, reduce, fold, join,
sum/min/max/average, every, isEmpty, isNull/isNotNull,
isString/isList/isMap, and the combinator/pipe family (identity,
tap, memoize, curried, pipe, …).
0.2.1 #
- READEME.md update
0.2.0 #
- Comprehensive docs site overhaul: tutorials for
curried/uncurriedandcreateSeededRandomnow part of the FxDart 101 course with live in-browser playground examples. - Logo and branding refresh for docs site.
- Enhanced playground bundle with full currying extensions support.
0.1.3 #
- Docs site: new tutorials for
curried/uncurriedandcreateSeededRandom(previously undocumented), wired into the FxDart 101 course; the playground bundle now includes the currying extensions.
0.1.2 #
.curried/.uncurriedextension getters (arity 2–5): a fully typed, Dart-native replacement for FxTScurry, resolved statically per arity. Design rationale in WHY_CURRIED.md. The untypedcurrystub's deprecation now points at.curried.
0.1.1 #
concurrentPoolnow eagerly keeps its pool full (FxTS behavior): even one-pull-at-a-time consumers liketoArray()get full overlap and completion-order results.- Docs site (GitHub Pages) with a live in-browser playground for every
function, under
docs/.
0.1.0 #
- Complete rewrite: port of FxTS to Dart.
- Lazy sync operators over plain
Iterables (map,filter,take,chunk,zip, ...). - Pull-based
FxAsyncIterableprotocol with FxTS-styleconcurrent(n)/concurrentPool(n)evaluation andStreambridges (fromStream,toStream). - Typed
fx()/FxAsyncchain API; dynamicpipe/pipeLazyfor parity. - Strict functions (
reduce,groupBy,sortBy,partition, ...), Map-based object functions (omit,pick,evolve,isMatch, ...), and Util (debounce,throttle,shuffle). - Unportable TS APIs kept as
@Deprecatedstubs (curry,isUndefined,isArray,isObject). - 850+ tests ported from the FxTS spec suite.
0.0.1 #
- Initial update, Add concept inspired by FxJs
