The ideas behind the pipeline — for working Dart developers
This is the theory companion to FxDart 101. The tutorials answer how do I call this function; this book answers why does the function have that shape, and what does the shape guarantee.
It is written for a working Dart developer. That means three things.
No prerequisites beyond Dart. No Haskell, no category theory, no mathematics past the idea that a function maps inputs to outputs. Where a concept has a formal definition, you get it — but after you have already used the thing it names.
Every listing runs. Code marked with a ▶ Run button compiles with the real Dart compiler and executes in this page. The first run downloads the compiler runtime and takes a few seconds; after that it is instant. Claims about what a program prints are meant to be checked, not believed.
Honesty over advocacy. Some of these ideas pay for themselves in the first hour. Some are elegant and, in Dart, not worth the friction — Dart cannot express several of them at all, and where that is the case this book says so and shows what FxDart does instead.
Use the arrows, the ← and → keys, or Contents to jump to a chapter. Each chapter ends with exercises; the solutions are on the following spread, so you can think before you turn.
Notation.
A,Bare ordinary types (int,User).M<A>is a value of typeAsitting inside some structureM—List<A>,Future<A>,Either<E, A>. A function writtenA → M<B>takes a plain value and returns one that is inside the structure. That single shape is what most of Part I is about.
In this chapter
- the three monads you already use in Dart, and what makes them one shape
- the two operations —
ofandflatMap— and why flattening is the point- the three laws, as code you can run, and what breaks when a type ignores them
- why Dart cannot declare a
Monadinterface, and what FxDart does instead
The famous definition — a monad is a monoid in the category of endofunctors — is true, and it is the worst possible first sentence. It describes the general case to someone who has not yet met a single instance. So here are three instances first. You have written all three.
import 'package:fxdart/fxdart.dart'; Either<String, int> parsePort(String text) => either((r) { final n = r.ensureNotNull( int.tryParse(text), () => 'not a number: $text'); r.ensure(n > 1023, () => 'privileged port: $n'); return n;}); Future<int> fetchTimeout(int port) async => port + 100; void main() async { // List<A>: many values in one structure. print([1, 2, 3].expand((x) => [x, x * 10]).toList()); // Either<String, int>: a value, or a failure instead of it. print(parsePort('8080')); print(parsePort('80')); // Future<A>: a value that is not here yet. print(await Future.value(8080).then(fetchTimeout));}Three unrelated types. List holds many values, Either holds one value or one failure, Future holds a value that has not arrived. What they share is not what they hold — it is what you can do with them.
Figure 1-1. Different contents, identical wiring: every one of them can take a plain value in, and every one of them can chain a function that hands back another box of the same kind.
Each of these types gives you two operations:
| put a value in | chain a step that returns another box | |
|---|---|---|
List<A> | [a] | expand |
Future<A> | Future.value(a) | then |
Either<E, A> | Either.right(a) | flatMap |
Fx<A> (FxDart) | fx([a]) | flatMap |
A type with those two operations, obeying three laws we will get to, is a monad. That is the whole definition. The word is intimidating because it arrived from category theory with its vocabulary attached, not because the idea underneath is large.
Write M<A> for a value of type A inside a structure M. A monad is a type constructor M plus:
pure, return, or unit): A → M<A>. Take an ordinary value, get the most boring possible box containing it. Boring is a technical requirement: Either.right(3) adds no failure, Future.value(3) adds no waiting, [3] adds no extra elements.bind or >>=): M<A> × (A → M<B>) → M<B>. Take a box, and a function that turns the value inside into another box, and get one box back — not a box of boxes.The second half of that last sentence is the entire point, and it is easiest to see by removing it. map alone is not enough:
void main() { // The step returns a List, so map gives a List of Lists. final nested = [1, 2, 3].map((x) => [x, x * 10]).toList(); print(nested); print(nested.runtimeType); // flatMap (Dart spells it `expand`) joins the inner lists // into the outer one. final flat = [1, 2, 3].expand((x) => [x, x * 10]).toList(); print(flat); print(flat.runtimeType);}Figure 1-2. Both operations apply the same function. map keeps the box the function returned, wrapping it in the box it started from; flatMap joins the two layers into one.
Why does that matter so much? Because a step that can fail, or wait, or produce many answers, is exactly a function of type A → M<B>. Real programs are sequences of those steps. With only map, each step adds a layer: three steps in a row give you Either<E, Either<E, Either<E, A>>>, and nothing can be done with that value without unwrapping it three times. flatMap keeps the depth at one, forever, no matter how many steps you chain. Monads are how you compose functions that return contexts.
Terminology. A type with only
map(obeying its own two laws) is a functor — Chapter 5. Every monad is a functor: you can definemap(f)asflatMap((a) => of(f(a))). The reverse is not true, which is why the tower has more than one floor.
Dart hides flatMap behind syntax you use daily. await is flatMap for Future: it takes the value out of a future, runs the rest of the function on it, and the result is one future — never a Future<Future<T>>. A for-in loop that appends to a list is flatMap for List. Chapter 14's either { } block is flatMap for Either.
Watch the same computation written both ways — first as an explicit chain, then in FxDart's either scope:
import 'package:fxdart/fxdart.dart'; Either<String, int> parseAge(String text) => either((r) { final n = r.ensureNotNull( int.tryParse(text), () => 'not a number: $text'); r.ensure(n >= 0, () => 'negative age: $n'); return n;}); Either<String, String> lookup(String id) => id == 'u1' ? Either.right('Ada') : Either.left('no such user: $id'); // Explicit chaining: every dependent step nests// one level deeper.Either<String, String> greetChained(String id, String ageText) => lookup(id).flatMap((name) => parseAge(ageText).flatMap((age) => Either.right('$name is $age'))); // The same steps in a Raise scope: straight-line code,// with the same short-circuiting.Either<String, String> greetScoped(String id, String ageText) => either((r) { final name = r.bind(lookup(id)); final age = r.bind(parseAge(ageText)); return '$name is $age';}); void main() { print(greetChained('u1', '36')); print(greetScoped('u1', '36')); print(greetScoped('u9', '36')); print(greetScoped('u1', 'old'));}Both versions do the same thing, including stopping at the first failure and never running the second step when the first one fails. The difference is that the chained version slides one indentation level to the right per step — the shape every language with monads eventually invents syntax to hide. Haskell calls its version do-notation, Scala calls it a for-comprehension, Dart calls the special case of it async/await. FxDart's either block is the same idea reached by a different mechanism, which is the subject of Chapter 15.
The two operations are not enough. A type could define of and flatMap and still behave surprisingly — so a monad must also obey three laws. They read as pedantic statements of the obvious, which is exactly what makes them valuable: they are the guarantees you already assume when you refactor.
of(a).flatMap(f) = f(a). Boxing a value and immediately chaining a step is the same as just calling the step.m.flatMap(of) = m. Unwrapping a box and putting the value straight back changes nothing.m.flatMap(f).flatMap(g) = m.flatMap((a) => f(a).flatMap(g)). How you group a chain of steps does not affect the result.Figure 1-3. Every law says the same kind of thing: two different routes through the diagram must arrive at the same value. The laws are what let you take either route.
Here they are as assertions you can run against FxDart's Either:
import 'package:fxdart/fxdart.dart'; Either<String, int> half(int n) => n.isEven ? Either.right(n ~/ 2) : Either.left('odd: $n'); Either<String, int> minusOne(int n) => Either.right(n - 1); void main() { final m = Either<String, int>.right(20); print( Either<String, int>.right(20).flatMap(half) == half(20)); print(m.flatMap((a) => Either<String, int>.right(a)) == m); print(m.flatMap(half).flatMap(minusOne) == m.flatMap((a) => half(a).flatMap(minusOne))); // The laws hold on the failure side too — that is what makes // short-circuiting composable rather than a special case. final bad = Either<String, int>.left('boom'); print(bad.flatMap(half).flatMap(minusOne) == bad.flatMap((a) => half(a).flatMap(minusOne)));}Laws are not decoration. Break one and ordinary refactoring silently changes behaviour. Here is a box that counts the steps taken — a plausible design, and unlawful:
class Logged<A> { const Logged(this.value, this.steps); final A value; final int steps; static Logged<A> of<A>(A value) => Logged(value, 0); // The `+ 1` is the bug: chaining charges for // the chaining itself. Logged<B> flatMap<B>(Logged<B> Function(A) f) { final next = f(value); return Logged(next.value, steps + next.steps + 1); } @override String toString() => 'Logged($value, steps: $steps)';} Logged<int> double_(int n) => Logged(n * 2, 1); void main() { // Left identity: of(a).flatMap(f) should equal f(a). // It does not. print(Logged.of(21).flatMap(double_)); print(double_(21)); // Right identity: chaining a step that does nothing // should be invisible. final m = double_(21); print(m); print(m.flatMap(Logged.of));}Associativity happens to survive here — regroup the chain and the count is unchanged — but both identity laws fail, and that is already fatal. Extracting a trivial step into its own flatMap, or inlining one away, is a refactor every reviewer would wave through, and in this type it changes the answer.
The fix is not to add a special case; it is to make steps a monoid — a type with an associative combine and an identity element (Chapter 8) — and let of produce the identity. Drop the + 1 and Logged becomes the Writer monad, lawful and useful. That is the pattern behind most law violations: an operation that looks harmless but has no identity element.
🎓 The formal definition, for the record. In category theory a monad on a category C is an endofunctor
T : C → Cwith two natural transformations,η : Id ⇒ T(that isof) andμ : T² ⇒ T(that isflatten, from whichflatMap(f) = μ ∘ T(f)), satisfying the unit and associativity coherence conditions — the three laws above, drawn as commuting diagrams. "A monoid in the category of endofunctors" says the same thing again:μis the multiplication,ηthe unit. Nothing in this paragraph will help you write Dart, which is why it is in a box, and why Chapter 20 is where it belongs.
Now the honest part. Dart cannot express the interface this chapter just described. Writing it down requires a type parameter that is itself generic — a higher-kinded type — and Dart has none:
// Does not compile. `M` is a type, and a type cannot take// arguments here.abstract class Monad<M> { M<A> of<A>(A value); M<B> flatMap<A, B>(M<A> box, M<B> Function(A) f);}
Kotlin's Arrow, the library FxDart's typed errors are ported from, works around this with compiler plugins and context receivers. Scala has the kind system natively. Dart has neither, and no amount of cleverness recovers it — attempts end in dynamic casts that give up exactly the type safety the abstraction existed to provide.
So FxDart does the only honest thing: it implements the shape, per type, and never pretends to abstract over it.
Either<L, R> has flatMap, and Either.right is its of. The laws hold; you ran the check two pages ago.either((r) { … }) is the ergonomic replacement for do-notation. It is not desugaring — r.bind short-circuits by raising into a scope (Chapter 15), a delimited-continuation trick rather than a monadic rewrite. Same straight-line code, different mechanism, and a distinction that matters when you ask why there is no Raise monad instance.Fx<A> is a lazy Iterable chain, and Iterable is the list monad: flatMap is its bind, fx([a]) its of. Laziness does not disturb the laws — Chapter 11 shows why evaluation order is invisible to them.FxAsyncIterable<A> is the same shape over asynchronous sources, with the extra property that concurrent(n) changes when elements are computed without changing which — an equational-reasoning claim the laws underwrite.What you lose by having no Monad interface is generic code that works for every monad at once: one traverse, one sequence, one set of combinators reused across Either, Fx, and Future. FxDart writes the concrete versions instead. That is more code in the library and less abstraction in your program — a trade the language chose, not the library.
You do not need the word "monad" to use await. The word starts paying when you notice the same problem in three places — nested callbacks, a pyramid of null checks, a chain of Eithers — and realise it is one problem with one solution shape. It pays again when a library gives you a type with flatMap and you can predict, without reading the source, what chaining it will do.
And it pays when you are choosing between designs: if your type has of and flatMap and the laws hold, users can refactor chains freely. If it has them and the laws do not hold, you have built a trap. Chapter 5 climbs down one floor to the functor and Chapter 6 to the applicative, where a great deal of practical validation code lives.
Set<A> has expand and {a}. Check the three laws with a step whose results collide — for example (x) => {x % 3} over {1, 2, 3, 4}. Is Set a monad? What does your answer depend on?map for Either using only flatMap and Either.right, then confirm it agrees with the built-in map on both a Right and a Left.Future has then. Is Future.value(a).then(f) really equal to f(a) — equal as values, or only in what they eventually produce? What does that tell you about which equality the laws are stated over?Logged so all three laws hold, then chain two steps in both groupings and show the counts agree.Set throws away order and duplicates on both sides of every law equally. {1,2,3,4}.expand((x) => {x % 3}) gives {1, 2, 0} either way you group it. The caveat is the point: a law is stated over an equality, and a type can be lawful under one notion of equality and unlawful under another — List under set equality is lawful; Set under "same insertion order" is not.Either<L, B> mapViaFlatMap<L, A, B>(Either<L, A> e, B Function(A) f) => e.flatMap((a) => Either.right(f(a)));. On a Left neither version calls f, which is left identity's fingerprint on the failure side.== on futures compares identity, so the law is stated over observational equality: the two programs produce the same value and the same effects. This is the equality every monad law is really about; the Either checks earlier in the chapter only got to use == because Either defines structural equality.+ 1 from flatMap and let double_ report its own cost: Logged(next.value, steps + next.steps). Now of contributes the identity element of +, chaining contributes nothing of its own, and all three laws hold — Logged.of(1).flatMap(f).flatMap(g) and Logged.of(1).flatMap((x) => f(x).flatMap(g)) both report 2.In this chapter
- referential transparency as a mechanical test you can apply to any expression
- the four capabilities purity buys — memoising, reordering, parallelising, testing
- where effects hide in ordinary Dart, including the ones that do not look like effects
- the seam: a pure core with effects pushed to the edge, and what FxDart gives you at that seam
A function is pure when a call to it can be replaced by its result everywhere, without changing what the program does. That property has a name — referential transparency — and it is a mechanical test, not a style preference.
int double_(int n) => n * 2; var log = <String>[];int doubleAndLog(int n) { log.add('doubled $n'); return n * 2;} void main() { // Substitution holds: double_(21) and 42 are the same thing. print([double_(21), double_(21)]); print([42, 42]); // Substitution fails: the two programs differ in `log`. print([doubleAndLog(21), doubleAndLog(21)]); print(log);}Both functions return the same number. Only one of them lets you rewrite the program around it. That difference — not the presence of the word void, not whether a linter complains — is what "pure" means.
Figure 2-1. Purity is the permission to redraw the left picture as the right one. Every refactoring you perform by hand is an appeal to this permission.
Four capabilities, and you already rely on all of them:
| Capability | Why purity is required |
|---|---|
| Memoise | Caching a result assumes the second call would have done the same thing |
| Reorder | Moving a line assumes nothing else observes when it ran |
| Parallelise | Running two calls at once assumes neither can see the other |
| Test | Asserting on a return value assumes the value is the whole story |
FxDart's memoize is the sharpest example: it is correct for a pure function and a silent bug for an impure one.
import 'package:fxdart/fxdart.dart'; int calls = 0;int slowSquare(int n) { calls++; return n * n;} void main() { final fast = memoize(slowSquare); print([fast(9), fast(9), fast(9)]); print('underlying calls: $calls');}Three calls, one evaluation. Nothing in memoize checks that slowSquare is pure — it assumes it. That is the shape of most functional machinery: the library provides the mechanism, the law provides the licence, and you are the one who has to keep the bargain.
An effect is anything a caller can observe besides the returned value, or anything the result depends on besides the arguments. Dart hides several in plain sight:
List.DateTime.now(), Platform.isIOS. Same arguments, different answers.createSeededRandom: a seed turns an effect back into an argument.print and IO — output is observable by definition.List equality is by reference, so returning a fresh list is observably different from returning a shared one under identical.import 'package:fxdart/fxdart.dart'; void main() { // A seed makes randomness reproducible: same input, same // output, so a shuffle becomes testable. final a = shuffle([1, 2, 3, 4, 5], 7); final b = shuffle([1, 2, 3, 4, 5], 7); print(a); print('reproducible: ${a.toString() == b.toString()}');}🎓 "Pure" is about the language's observation, not the universe. A pure function still burns CPU, allocates, and heats the room. Purity is defined relative to what the program can observe: two expressions are interchangeable if no Dart code can tell them apart. Time and memory are outside that lens — which is exactly why Chapter 14 has to measure them separately, and why "pure" never means "free".
Nobody ships a program with no effects; the goal is to know where they are. The standard arrangement is a pure core with an effectful shell: parse, decide, and compute in pure functions; read and write at the edges.
Pipelines make the seam visible, because a lazy pipeline is a description of work rather than the work itself. Compare where the effect sits:
import 'package:fxdart/fxdart.dart'; class Order { const Order(this.id, this.total, this.status); final String id; final int total; final String status;} const orders = [ Order('a', 120, 'paid'), Order('b', 40, 'refunded'), Order('c', 260, 'paid'),]; // Pure core: data in, data out. No printing, no clock, no IO.List<String> receipts(Iterable<Order> all) => fx(all) .filter((o) => o.status == 'paid') .sortByDesc((o) => o.total) .map((o) => '${o.id}: ${o.total}') .toList(); void main() { // Effectful shell: the one place that touches the world. receipts(orders).forEach(print);}receipts is testable by equality alone, and peek gives you a declared seam for the times you need to observe a pipeline without breaking that property — it is a labelled effect rather than a hidden one:
import 'package:fxdart/fxdart.dart'; void main() { final seen = <int>[]; final result = fx(range(1, 6)) // the effect is named, and it is the only one .peek(seen.add) .filter((n) => n.isEven) .toList(); print(result); print(seen);}Purity is not a virtue you accumulate; it is leverage you spend. It pays when you need to cache, retry, reorder, run concurrently, or write a test that does not need a fixture — which is to say, in exactly the situations Parts III and IV are about. concurrent(n) (Chapter 13) is only safe because the callbacks it runs out of order cannot see each other.
It costs when the effect is the point. A logger, a migration script, a UI event handler: wrapping those in ceremony buys nothing. Chapter 22 makes that case at length.
List.of(items) pure? Consider both == and identical as the way a caller might observe the result.memoize on a function of type int Function(int) is safe. What goes wrong if the argument type is a mutable List<int>?receipts pipeline above and add a requirement: log every order that was filtered out. Do it without making receipts impure.==, impure by identical. Two calls with the same argument return equal lists but never the same object, so a program that compares with identical can tell the calls apart. This is why "pure" is always stated relative to an observation — the same subtlety appears in Chapter 1's exercise about Future equality.class Rate { const Rate(this.pct); final int pct; int apply(int n) => n * pct ~/ 100; }. It is referentially transparent because pct cannot change; the instance is part of the input, just spelled as a receiver rather than an argument. Drop final and the same call can return two answers, so substitution fails.memoize keys on the argument, and a mutable list's contents can change after it is used as a key — a caller mutates the list, calls again, and gets the answer for the old contents. The cache is not wrong; the assumption was.fork or a partition-style split makes the function total in what it reports, and the caller (the shell) decides what to print. If you only need to observe, use .peek(rejected.add) on the rejected branch: still a declared effect at a named seam, still no IO inside the core.In this chapter
- products and sums: the two ways types combine, and how to count their values
- why
sealed+switchis the feature that makes sum types worth using- the refactor: replace a bag of nullable fields with a type that cannot lie
- where records fit, and where a type is the wrong tool
A type is a set of values, and you can count it. bool has 2. Null has 1. An enum with three constants has 3. Once you can count, the two ways of combining types get their names:
(bool, bool) has 2 × 2 = 4 values. Fields of a class are a product.bool | Null has 2 + 1 = 3 values. In Dart, a sealed hierarchy is a sum, and so — informally — is T?.Design bugs are almost always the same bug: the type has more values than the domain does. Here is the classic form.
// Four fields; 2 × 2 × 2 × 2 = 16 representable combinations…class Request { Request( {this.loading = false, this.data, this.error, this.cancelled = false}); final bool loading; final String? data; final String? error; final bool cancelled;} void main() { // …but this one is nonsense, and it compiles. final broken = Request(loading: true, data: 'ok', error: 'boom'); print([broken.loading, broken.data, broken.error]);}Four states are meaningful — loading, loaded, failed, cancelled — and the type admits sixteen. The twelve extra ones are where the bugs live, and every if (r.error != null && !r.loading) in the codebase is a hand-written patch over one of them.
Figure 3-1. The type on the left is a product of four flags; the domain is a sum of four cases. Every cell outside the diagonal is a state your code must either handle or hope never happens.
sealed class Request { const Request();} class Loading extends Request { const Loading();} class Loaded extends Request { const Loaded(this.data); final String data;} class Failed extends Request { const Failed(this.message); final String message;} class Cancelled extends Request { const Cancelled();} String render(Request r) => switch (r) { Loading() => 'spinner', Loaded(:final data) => 'showing $data', Failed(:final message) => 'error: $message', Cancelled() => 'cancelled',}; void main() { const all = [ Loading(), Loaded('42 rows'), Failed('timeout'), Cancelled() ]; all.map(render).forEach(print);}Four cases, exactly four states, no nullable fields, and no default arm. That last detail is the whole point: sealed makes the switch exhaustive, so adding a fifth case turns every place that handles the type into a compile error listing precisely what you have not thought about yet. A sum type without exhaustiveness checking is just a class hierarchy with extra steps; Dart 3 supplied the missing half.
This is the same machinery Either uses — it is a sealed sum of Left and Right (Chapter 16), which is why switch over an Either needs no fallback arm either.
Records give you an anonymous product where a class would be ceremony:
import 'package:fxdart/fxdart.dart'; void main() { // `attach` pairs each value with something derived from it: // a product, produced lazily, with no class to declare. final priced = fx(['apple', 'fig', 'banana']) .attach((name) => name.length) .toList(); print(priced); final total = fx(priced).sumBy((row) => row.$2); print('total letters: $total');}A record is the right tool when the pairing is local — an intermediate step in a pipeline, a two-value return. It is the wrong tool once the pairing has a name in the domain and rules attached to it, because a record cannot carry an invariant. (String, int) cannot promise the int is non-negative; class Money { Money(this.cents) : assert(cents >= 0); } can.
🎓 Why "algebraic" data types. Products multiply their sizes and sums add them, and the algebra keeps going:
Either<A, B>has |A| + |B| values,A?has |A| + 1, and functionsA → Bhave |B|^|A| — which is why the arrow is written as exponentiation in the literature. The isomorphism(A, B) → C ≅ A → (B → C)— currying, Chapter 4 — is the type-level statement that(c^b)^a = c^(b×a). The names are not decoration; the arithmetic is real, and it predicts which refactorings preserve meaning.
sealed subclass each, each carrying exactly the data that case needs — Loaded has data and no message, and no nullable anything.if (x != null && !y) that existed to rule out an impossible combination goes away, replaced by a switch arm the compiler watches.The payoff is not elegance, it is that the next change is checked. Adding Retrying to Request produces a list of compile errors, which is a to-do list written by the compiler and impossible to forget.
Use it where a wrong combination would be a real defect and where cases will grow: request/response states, parse results, protocol messages, anything with "or" in its specification.
Skip it for genuinely open-ended data, for a struct that is just three independent numbers, and — importantly — at the boundary with JSON, where the world hands you a bag of nullables regardless. There, the sum type is what you parse into: one place converts the shapeless map into a value that cannot lie, and everything downstream gets the guarantee. That parse is the subject of Part IV.
(bool, String?) have if String has n values? And Either<bool, bool>?Request class at the top of the chapter and write down the twelve nonsense states. Which of them would your codebase currently crash on, and which would silently render something wrong?Either<String, int> and (String?, int?) can both represent "a failure or a number". Give a concrete reason to prefer the first.(bool, String?) is a product of 2 and (n + 1), so 2n + 2 values. Either<bool, bool> is a sum: 2 + 2 = 4 — the same count as (bool, bool), but they are different types, and confusing the two is precisely the modelling error this chapter is about.String reason: sealed class Light with Red, Amber, Green, FlashingAmber(reason). The type admits 3 + r states, where r is the number of reason strings — which is honest, because a flashing amber genuinely does carry more information than a red.loading with data, loading with error, data with error, cancelled with anything else, and the empty state where all four are null/false. The empty one is usually the crash (nothing to render); the combinations are usually the silent bug, because the first if in the render function wins and the rest of the state is discarded unread.Either is a sum, so the compiler can prove exactly one side is present and switch covers both without a fallback. (String?, int?) is a product of two optionals: four states, two of which — both null, both non-null — are nonsense you must handle by hand at every use site.In this chapter
- composition as the operation that turns two functions into one
- partial application and currying, and the difference between them
- why a faithful curried
pipecannot be typed in Dart- what FxDart ships instead, and the price of that choice
Two functions line up when one's output is the other's input, and composing them produces a third function that mentions no intermediate value:
import 'package:fxdart/fxdart.dart'; String trim(String s) => s.trim();String upper(String s) => s.toUpperCase(); // Dart has no composition operator, so composition is a// three-line helper. Its shortness is the point: the concept// is small, only the notation is missing.C Function(A) compose2<A, B, C>( B Function(A) f, C Function(B) g) => (a) => g(f(a)); void main() { // By hand. String shout(String s) => upper(trim(s)); print(shout(' hello ')); // As a value: the composition is itself passable. final shout2 = compose2(trim, upper); print(shout2(' hello ')); print([' a ', ' b'].map(shout2).toList()); // pipe1 is the same idea with the value supplied first. print(pipe1(' hi ', shout2));}Composition is associative — (f ∘ g) ∘ h equals f ∘ (g ∘ h) — and the identity function is its unit. That is a monoid (Chapter 8), and it is the reason you can group a pipeline's stages any way you like without changing the result. It is also the reason "extract a helper" is always safe: pulling three chained steps into a named function is exactly the regrouping the law permits.
They get used interchangeably and they are not the same thing.
add(2, _) becomes a one-argument function.int Function(int, int) becomes int Function(int) Function(int). Partial application is then just calling the first layer.import 'package:fxdart/fxdart.dart'; int addTwo(int a, int b) => a + b; void main() { // Currying: one call per argument. final curriedAdd = addTwo.curried; final add10 = curriedAdd(10); print([add10(5), add10(32)]); // Partial application without currying: a closure does it too. int Function(int) addAlso(int a) => (b) => a + b; print(addAlso(10)(32)); // Uncurrying goes back. print(curriedAdd.uncurried(40, 2));}Figure 4-1. Composition joins two machines end to end and hides the join. Currying re-slots one machine with two inputs as two machines with one input each.
pipe could not be portedFxTS is built on a curried pipe: every operator is a function that takes its callback and returns a function awaiting the data, and pipe threads a value through a list of them. TypeScript types that with ~20 hand-written overloads, one per arity, and variadic tuple types to relate them.
Dart has neither overloads nor variadic generics. A pipe that accepts any number of stages has to fall back on dynamic:
// FxDart ships this for FxTS parity — and every stage boundary// is an unchecked cast.final result = pipe( [1, 2, 3, 4], (dynamic xs) => map((dynamic n) => (n as int) * 2, xs as Iterable), (dynamic xs) => toList(xs as Iterable<int>),);
Every stage boundary is an unchecked cast. The type error you wanted the compiler to catch — a String stage in an int pipeline — now arrives at runtime, in the middle of a lazy iterator, with a stack trace that points at library internals.
So FxDart chose a different shape for the same idea:
import 'package:fxdart/fxdart.dart'; void main() { final result = fx([1, 2, 3, 4, 5, 6]) .map((n) => n * 2) .filter((n) => n > 4) .take(3) .toList(); print(result);}The chain is a typed composition: each method returns Fx<R> with the new element type, so the compiler follows the value all the way down, and your editor can complete it. What it gives up is the ability to hold a stage as a first-class value and pass it around — in FxTS, map(f) alone is a value; in FxDart it is a method call that needs a receiver. WHY_CURRIED.md in the repository records that trade in full.
🎓 Currying is an isomorphism, not a convention.
(A, B) → CandA → (B → C)carry exactly the same information — you can convert either way without loss, which is what.curried/.uncurrieddemonstrate at runtime. Languages that curry by default (Haskell, OCaml) picked one side of the isomorphism as primitive; Dart picked the other. Nothing is expressible in one that is not expressible in the other — only the ergonomics differ, and ergonomics is exactly why the choice matters.
A function that takes or returns a function is higher-order, and the pipeline vocabulary is nothing but higher-order functions: map, filter, fold, sortBy all take behaviour as an argument. Two more from FxDart worth knowing by name:
import 'package:fxdart/fxdart.dart'; bool small(int n) => n < 10;bool odd(int n) => n.isOdd; void main() { // juxt: one input, several functions, all their results. final stats = juxt([ (Iterable<int> xs) => xs.length, (Iterable<int> xs) => xs.reduce((a, b) => a + b), ]); print(stats([3, 1, 4, 1, 5])); // Predicates are values too, so they combine. final both = (int n) => small(n) && odd(n); print(fx([3, 12, 7, 20]).filter(both).toList()); print(fx([3, 12, 7, 20]).filter(negate(small)).toList());}Treating functions as values pays when behaviour varies but structure does not — one pipeline, four policies passed in; one validator, composed from small named rules. It also pays at test time: a function parameter is the cheapest seam there is, and needs no mocking framework.
It stops paying when the composition gets longer than the thing it replaced. A chain of six point-free combinators that a reader must mentally apply to a value is worse than a for loop with a good name. Dart's lack of a composition operator makes this threshold arrive sooner than in Haskell, and pretending otherwise is how FP code earns its reputation.
compose2(f, g) applies f first. Haskell's . operator applies the right-hand function first. Which order does fx(...).map(f).map(g) use, and why is that the only sane choice for a method chain?compose3 for three one-argument functions using compose2 twice. Then argue that the two ways of grouping the calls give the same function.addTwo.curried(10) returns a function. What is its type, spelled out in full? Why can Dart not infer a curried getter for a function of arbitrary arity?fx(xs).filter(small).filter(odd) as a single filter. Is that always a safe refactor? What property of filter does it rely on?map(f) then map(g), matching reading order. A method chain has to — the receiver is on the left, so the first thing written is the first thing applied. Haskell's . reads right to left because it mirrors mathematical f ∘ g; both are consistent, and mixing them in one codebase is the actual hazard.D Function(A) compose3<A, B, C, D>(...) built as compose2(compose2(f, g), h) or compose2(f, compose2(g, h)). Same function because composition is associative — the same law that lets you regroup pipeline stages, and the same shape as monad associativity in Chapter 1.int Function(int). Dart cannot express "a function of any arity" as a type parameter, so curried is written out once per arity — Curry2 through Curry5 extensions on R Function(A, B), R Function(A, B, C), and so on. It is the same wall as the missing variadic generics in pipe, and the same wall as higher-kinded types in Chapter 10: Dart's type system is deliberately first-order.fx(xs).filter((n) => small(n) && odd(n)). It is safe when the predicates are pure — the fused version calls small and odd on the same element in the same order, and short-circuits identically. If a predicate has a side effect (counting how many elements it saw, say), the two versions differ: the chained form runs odd only on survivors, and so does the fused one, but an effect ordered between the two filters would move. Purity is what makes fusion a refactor rather than a rewrite.In this chapter
- the functor: one operation,
map, with two laws- what the laws forbid, shown with a type that breaks them
- why the composition law is what licenses stage fusion in a pipeline
- functors that are not containers, including the one hiding in
Function
A functor is a type F with a single operation:
map : F<A> × (A → B) → F<B>
Take a structure holding As and a plain function A → B, get the same structure holding Bs. "Same structure" is doing the real work in that sentence, and the two laws are what pin it down.
Dart is full of functors and calls them different things:
import 'package:fxdart/fxdart.dart'; void main() { print([1, 2, 3].map((n) => n * 2).toList()); // List print(Either<String, int>.right(20).map((n) => n * 2)); print(Either<String, int>.left('nope').map((n) => n * 2)); print(fx([1, 2, 3]).map((n) => n * 2).toList()); // Fx}Notice the third line. Mapping a Left does nothing, and that is not a special case bolted on — it is forced. map may not change the structure, and for Either the choice of side is the structure. A map that turned a Left into a Right would be some other function wearing the name.
m.map((x) => x) == m. Mapping the identity function changes nothing at all — not the values, not the shape, not anything observable.m.map(f).map(g) == m.map((x) => g(f(x))). Two passes with two functions equal one pass with their composition.import 'package:fxdart/fxdart.dart'; int addOne(int n) => n + 1;int triple(int n) => n * 3; void main() { final m = Either<String, int>.right(7); // identity print(m.map((x) => x) == m); // composition print(m.map(addOne).map(triple) == m.map((x) => triple(addOne(x)))); // both hold on the other side too final bad = Either<String, int>.left('boom'); print(bad.map((x) => x) == bad);}Figure 5-1. Identity says the loop does nothing. Composition says the two routes across the square land on the same value — which is why a pipeline may be re-cut anywhere between stages.
They rule out a map that does anything besides apply the function. Here is a plausible type that fails:
// A box that remembers how many times it was mapped.class Counted<A> { const Counted(this.value, this.maps); final A value; final int maps; Counted<B> map<B>(B Function(A) f) => Counted(f(value), maps + 1); @override bool operator ==(Object other) => other is Counted && other.value == value && other.maps == maps; @override int get hashCode => Object.hash(value, maps); @override String toString() => 'Counted($value, maps: $maps)';} void main() { final m = Counted(7, 0); // Identity fails: mapping "nothing" is observable. print(m.map((x) => x) == m); // Composition fails: two passes cost two, one pass costs one. print(m.map((x) => x + 1).map((x) => x * 3)); print(m.map((x) => (x + 1) * 3));}The type is not wrong — counting maps might be exactly what you want. What it is not is a functor, and the practical consequence is precise: a reader may no longer fuse or split its map calls, because doing so changes the result. Laws are permissions, and this type withholds one.
Read the composition law from right to left and it stops being philosophy:
m.map(f).map(g) — two traversals — is equal to m.map(g ∘ f), one traversal. A library may therefore rewrite the first into the second whenever it likes, and you never find out.
That is not hypothetical in FxDart. Lazy pipelines fuse stages so that a value flows through the whole chain once rather than being materialised between steps, and the licence for that rewrite is the functor law:
import 'package:fxdart/fxdart.dart'; void main() { final seen = <String>[]; final result = fx([1, 2, 3]) .map((n) => n + 1) .peek((n) => seen.add('after +1: $n')) .map((n) => n * 3) .peek((n) => seen.add('after *3: $n')) .toList(); print(result); // Interleaved, not staged: element by element through the // whole chain — the one-pass reading of the composition law. seen.forEach(print);}Two maps and no intermediate list. In an eager language you would pay for one list per stage; here the law says you do not have to, and the implementation takes the law up on it. Chapter 11 makes this evaluation story explicit.
🎓 Functor, formally. A functor is a mapping between categories that sends objects to objects and arrows to arrows while preserving identity and composition — which is exactly the two laws, stated once for the general case. In programming we only ever use endofunctors on the category of types:
Fmaps the typeAto the typeF<A>, andmaplifts an arrowA → Bto an arrowF<A> → F<B>. Chapter 20 draws the diagram; nothing above depends on it.
"A functor holds values" is a useful lie. What a functor really has is a position the function can act on, and some of those positions hold nothing at all.
Future<A> — the value is not here yet; then is its map.map changes what a future parse will produce.Function(X) → A — the reader functor. Mapping over a function composes onto its result:void main() { int Function(String) length = (s) => s.length; // map for functions IS composition: apply, then transform. int Function(String) doubledLength = (s) => length(s) * 2; print([length('functor'), doubledLength('functor')]);}That last one is worth sitting with: composition and map are the same operation seen from two angles, which is why Chapter 4's associativity and this chapter's composition law feel like the same sentence twice. They are.
The word pays off as a prediction tool. Meet an unfamiliar type with a map and you already know three things: it will not change the shape, mapping identity is a no-op, and you may split or fuse the calls freely. That is a lot of knowledge for one word.
It also tells you when a type is lying. A map whose documentation mentions retries, ordering changes, or caching is not a functor's map, and you should read the source before you refactor around it.
Either.map satisfies the identity law. How many cases are there, and why is that number the whole proof?Set has a map. Does it satisfy the composition law when f maps two distinct elements to the same value? Try {1, 2} with f = (x) => 0 and g = (x) => x + 1.map obeying both laws, is map unique? That is, could there be two different lawful maps for the same type — and does the answer differ for List vs Either?peek returns the same element type. Is peek a map? What law does it break, and which chapter's vocabulary explains why nobody minds?Left(e).map(id) returns Left(e) by definition, and Right(a).map(id) returns Right(id(a)) = Right(a). Either is a sum with exactly two constructors, so covering both is covering every value — the same exhaustiveness that Chapter 3 got from sealed.{1, 2}.map(f) is {0} and mapping g gives {1}; the fused g ∘ f gives {1} too. Deduplication happens on the way out in both routes. What Set breaks is not composition but the intuition that a functor preserves size — nothing in the laws promises that.List, no: a map that also reversed the list satisfies identity (reversing twice? no — reversing once breaks identity, since xs.map(id) would be xs.reversed). The interesting answer is that the laws pin map down for any type whose shape is determined by its contents' positions, which covers List and Either both. In practice, for these types the lawful map is unique, and that uniqueness is why the name can be trusted.peek is not a map — it is map with an effect attached, so it breaks the identity law the moment the callback does anything observable (peek((_) {}) is a no-op, peek(print) is not). Chapter 2's vocabulary is the explanation: peek exists precisely to make an effect declared, and a declared effect is not a violation but a documented exception.In this chapter
- the difference between dependent and independent steps, in types
- the applicative: combining several structures without either seeing the other
- why accumulating every error is impossible for a monad and natural here
- FxDart's
map2,zipOrAccumulate, and theaccumulatescope
Chapter 1's flatMap composes steps where the second depends on the first: you cannot look up the user's order until you have the user. That dependency is written into the type — A → M<B> takes the value out of the first box.
But a great deal of real code has no such dependency. Validating a form: the name check does not need the age, and the age check does not need the name. They are independent, and the type of the operation that combines them says so:
map2 : F<A> × F<B> × ((A, B) → C) → F<C>
No arrow from A into the second structure. Both are already there; the function only combines the results. A type with map2 (plus a way to lift a plain value, exactly Chapter 1's of) is an applicative functor.
Figure 6-1. flatMap cannot start the second step until the first produces a value. map2 has both from the start — which is what makes running them concurrently, or reporting both failures, even a possibility.
map2import 'package:fxdart/fxdart.dart'; class User { const User(this.name, this.age); final String name; final int age; @override String toString() => 'User($name, $age)';} Either<String, String> vName(String s) => s.isEmpty ? Either.left('name is empty') : Either.right(s); Either<String, int> vAge(String s) { final n = int.tryParse(s); if (n == null) return Either.left('age is not a number'); if (n < 0) return Either.left('age is negative'); return Either.right(n);} void main() { print(vName('Ada').map2(vAge('36'), User.new)); print(vName('').map2(vAge('36'), User.new)); // Both wrong — but only the leftmost failure is reported. print(vName('').map2(vAge('nope'), User.new));}The last line is the problem this chapter exists to solve. The user filled in two fields wrong; the form told them about one. Nothing about the structure forced that — both Eithers were computed. It is the reporting that is fail-fast, and map2 reports only the leftmost.
Try to write the accumulating version with flatMap alone and you hit a wall that is not a limitation of effort:
name.flatMap((n) => age.flatMap((a) => Either.right(User(n, a))));
If name is a Left, the outer flatMap short-circuits — and the function that would have looked at age never runs, because it is inside the callback. flatMap's type says the second step is a function of the first value, so when there is no first value there is no second step. Short-circuit is not a policy choice here; it is what the type means.
The applicative is strictly weaker, and the weakness is the feature. map2 holds both structures as data before combining them, so an implementation is free to look at both and concatenate their failures.
Dart has no Validated type; FxDart follows Arrow 2.x and provides an accumulating scope instead. Inside either, ask for one:
import 'package:fxdart/fxdart.dart'; class User { const User(this.name, this.age); final String name; final int age; @override String toString() => 'User($name, $age)';} Either<Nel<String>, User> parse(String name, String age) => either((r) => r.zipOrAccumulate2( (br) { if (name.isEmpty) br.raise('name is empty'); return name; }, (br) { final n = int.tryParse(age); if (n == null) br.raise('age is not a number'); if (n! < 0) br.raise('age is negative'); return n; }, User.new, )); void main() { print(parse('Ada', '36')); print(parse('', '36')); print(parse('', 'nope')); // both failures, in branch order}Every branch runs; failures concatenate into a NonEmptyList (Chapter 8 explains why that type and not a plain List). For more than five branches, or for rules that depend on earlier ones, drop to the full scope:
import 'package:fxdart/fxdart.dart'; Either<Nel<String>, String> checkout( String item, String qty, String coupon,) => either((r) => r.accumulate((acc) { final i = acc.accumulating((br) { if (item.isEmpty) br.raise('item required'); return item; }); final q = acc.accumulating((br) { final n = int.tryParse(qty); if (n == null) br.raise('qty is not a number'); return n ?? 0; }); // Dependent rule: only meaningful once qty parsed. final c = acc.dependent((br) { if (coupon.isNotEmpty && q.value > 10) { br.raise('coupon not valid in bulk'); } return coupon; }); return '${q.value} x ${i.value} ${c.value}'.trim(); })); void main() { print(checkout('mug', '2', '')); print(checkout('', 'x', 'SAVE5')); print(checkout('mug', '99', 'SAVE5'));}accumulating runs independent branches and records their errors; dependent runs only when nothing has failed yet, because a rule that reads another branch's value cannot run when that value does not exist. That split — independent versus dependent — is this chapter's distinction, made into API.
🎓 The laws, and the real definition. An applicative is usually given as
pure : A → F<A>plusap : F<A → B> × F<A> → F<B>(a function inside the structure, applied to a value inside the structure).map2andapare interdefinable, andmap2reads better in a language without currying by default, which is why FxDart exposes that face. The four laws — identity, composition, homomorphism, interchange — say what you would expect:pureadds nothing, and application is associative in the same way composition is. Every monad is an applicative (map2viaflatMap); the converse fails, and this chapter's validation is the standard counterexample.
| You need | Use | Because |
|---|---|---|
| Step 2 needs step 1's value | flatMap / either scope | The dependency is real |
| Steps are independent, first failure is enough | map2 | Cheapest, and short-circuits |
| Steps are independent, report every failure | zipOrAccumulate / accumulate | Only the applicative shape can |
| Steps are independent and slow | Applicative + concurrency | Independence is what makes overlap legal |
The last row is the one people miss. concurrent(n) (Chapter 13) is applicable exactly when steps do not depend on each other — the same condition that makes error accumulation possible. Independence buys you both, and flatMap spends it.
Form and payload validation, obviously. Also: configuration loading (report every missing key at once, not the first), CSV import (every bad row, not row 7), and anywhere a human will read the errors and fix them in one pass. The test is simple — would the user rather see all the problems at once? If yes, you want the applicative.
Skip it when failures are genuinely sequential (you cannot check the order until the user exists) or when there is exactly one thing that can go wrong. accumulate around a single rule is ceremony with no payoff.
Future has a map2-shaped combinator in the standard library. Name it, and explain why it can run both futures at once while f1.then((_) => f2) cannot.map2 for Either using only flatMap and map. Then explain why the version you wrote cannot accumulate errors, in one sentence about types.checkout example, swap dependent for accumulating in the coupon rule and predict what checkout('', 'x', 'SAVE5') prints. Why is dependent the safer default for rules that read siblings?Set an applicative? What would map2 mean, and does it match your intuition about "combining two sets"?Future.wait([a, b]) — it receives both futures already constructed, so both are running before it is called. a.then((_) => b) builds b inside a callback, so b cannot even exist until a completes. The difference is exactly map2 versus flatMap, and it is visible in the wall-clock time.a.flatMap((x) => b.map((y) => f(x, y))). It cannot accumulate because b.map sits inside a function of x: when a is a Left, that function is never applied, so b's failure is never examined. The type A → Either<E, C> is what makes the second value inaccessible.accumulating, the coupon branch runs even though qty failed, and reading q.value inside it detonates — raising the accumulated errors from inside a branch rather than at the end. dependent exists to make that impossible: it skips the block entirely when errors already exist, which is the right default for any rule that reads a sibling's .value.map2 on sets is the Cartesian product with the results deduplicated — {1,2} and {10,20} with + give {11, 21, 12, 22}. It matches the nondeterminism reading (each set is "one of these values"), which is the same reading that makes List a monad in Chapter 1. It does not match the zip-style intuition — and choosing between those two readings is exactly why Haskell has both [] and ZipList as separate applicatives.In this chapter
- Kleisli composition: why
A → M<B>functions need their own∘- the pyramid, and the syntax every language invents to flatten it
async/awaitread as do-notation for exactly one monad- why "one monad at a time" is the real limit, and what it costs in Dart
Chapter 4 composed A → B with B → C and got A → C. Try the same with steps that can fail:
parseId : String → Either<E, int>loadUser : int → Either<E, User>They do not line up. parseId's output is Either<E, int>, and loadUser wants a bare int. Ordinary composition is off the table, and this is not an edge case — every effectful step has this shape.
flatMap is the fix, and giving it a composition operator makes the pattern visible:
import 'package:fxdart/fxdart.dart'; // Kleisli composition: compose two "returns a box" functions.Either<E, C> Function(A) kleisli<E, A, B, C>( Either<E, B> Function(A) f, Either<E, C> Function(B) g,) => (a) => f(a).flatMap(g); Either<String, int> parseId(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad id: $s') : Either.right(n);} Either<String, String> loadUser(int id) => id == 1 ? Either.right('Ada') : Either.left('no user $id'); void main() { final lookup = kleisli(parseId, loadUser); print(lookup('1')); print(lookup('2')); print(lookup('x'));}kleisli composes A → M<B> with B → M<C> into A → M<C>. Those arrows form their own category — the Kleisli category of the monad — and the three laws from Chapter 1 are exactly what a category needs: of is the identity arrow (left and right identity), and flatMap is associative composition.
That is the whole content of "a monad is a way to compose effectful functions": flatMap restores composition after effects break it.
Figure 7-1. Plain functions click together. Effectful ones do not — the output has a wrapper the next input cannot accept. flatMap is the adapter, and the laws say the adapter is invisible.
Compose three or four dependent steps by hand and the code drifts right:
parseId(raw).flatMap((id) => loadUser(id).flatMap((user) => loadOrders(user).flatMap((orders) => Either.right(summarise(user, orders)))));
Every language with monads eventually grows syntax that flattens this. Same computation, four surfaces:
| Language | Syntax | What the compiler emits |
|---|---|---|
| Haskell | do { id <- parseId raw; … } | >>= chain |
| Scala | for { id <- parseId(raw) } yield … | flatMap/map chain |
| Kotlin (Arrow) | either { val id = parseId(raw).bind() } | a scope with a non-local exit |
| Dart | either((r) { final id = r.bind(parseId(raw)); … }) | a scope with a non-local exit |
The first two are desugaring: the compiler rewrites the block into method calls, and it works for any monad the type checker can name. The last two are not — there is no rewrite, just a scope object whose bind can abandon the block. Chapter 15 is about that mechanism and why Dart forced it.
The result reads the same either way:
import 'package:fxdart/fxdart.dart'; Either<String, int> parseId(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad id: $s') : Either.right(n);} Either<String, String> loadUser(int id) => id == 1 ? Either.right('Ada') : Either.left('no user $id'); Either<String, List<String>> loadOrders(String user) => user == 'Ada' ? Either.right(['mug', 'book']) : Either.left('none'); Either<String, String> summary(String raw) => either((r) { final id = r.bind(parseId(raw)); final user = r.bind(loadUser(id)); final orders = r.bind(loadOrders(user)); return '$user bought ${orders.length} things'; }); void main() { print(summary('1')); print(summary('2')); print(summary('nope'));}Straight-line code, three dependent steps, one failure type, and no pyramid.
async/await is do-notation for one monadDart already ships this idea — for Future, and only for Future:
Future<int> parseId(String s) async => int.parse(s);Future<String> loadUser(int id) async => id == 1 ? 'Ada' : 'nobody'; Future<String> summary(String raw) async { final id = await parseId(raw); // r.bind, spelled `await` final user = await loadUser(id); return 'user: $user';} void main() async { print(await summary('1')); print(await summary('7'));}Line for line, this is the either block above with await where r.bind was. async marks the scope; await unwraps one layer; the compiler rewrites the body into continuations, which is flatMap by another name. The evidence that it is monadic and not magic: await on a Future<Future<T>> gives you Future<T> — flattening, exactly as Chapter 1 required.
What Dart did not do is generalise it. await works on Future (and anything with a then, by structural luck), and there is no await for Either, no await for Iterable, no way to write your own. Every language in the table above made the same choice at first and then generalised; Dart's async is where that generalisation stopped.
🎓 Monads do not stack. Given
Future<Either<E, A>>you have two monads and no singleflatMapfor the pair. Scala reaches for monad transformers (EitherT[Future, E, A]), a wrapper per combination, with a tower of lifts. Kotlin and Dart avoid the tower by making the scope do double duty:eitherAsyncgives you aRaisescope inside anasyncbody, soawaithandles time andr.bindhandles failure, with no third type. It is not more powerful than transformers — it is less general and much easier to read, and Chapter 21 records who paid what for that trade.
import 'package:fxdart/fxdart.dart'; Future<Either<String, int>> fetchPort(String key) async => key == 'http' ? Either.right(8080) : Either.left('unknown: $key'); Future<Either<String, String>> describe(String key) => eitherAsync((r) async { // `await` sequences time; `r.bind` sequences failure. final port = r.bind(await fetchPort(key)); return 'listening on $port'; }); void main() async { print(await describe('http')); print(await describe('gopher'));}Two effects, one straight-line block, no EitherT. The cost is that this only works for the combinations FxDart wrote by hand — eitherAsync, nullable, catching. There is no generic mechanism you can extend, because expressing "any monad" needs a type feature Dart does not have. That is Chapter 10.
Reach for the scope whenever three or more dependent steps can fail with the same error type — parse, load, authorise, compute. That is the shape where the pyramid appears, and the shape where a hand-rolled if (x == null) return null chain quietly loses the reason for failing.
Do not reach for it when the steps are independent (Chapter 6: you lose accumulation and concurrency), when there is exactly one step (a plain Either.map says more), or when the failure is genuinely exceptional and the caller cannot act on it (Chapter 18).
kleisli for Future — compose A → Future<B> with B → Future<C>. Which existing Dart method is it a thin wrapper around?Either is Either.right. Show that kleisli(Either.right, f) and kleisli(f, Either.right) both behave like f, and name the two monad laws you just used.summary block using only flatMap, then count the lines and the maximum indentation of each version. At how many steps does the scope version start to win?await flattens Future<Future<T>>. What does that tell you about Future.then's type signature, compared with the map of Chapter 5?Future<C> Function(A) k<A, B, C>(Future<B> Function(A) f, Future<C> Function(B) g) => (a) => f(a).then(g);. It wraps then, which is Future's flatMap — the same method that also serves as its map, which is the subject of exercise 4.kleisli(Either.right, f) applied to a is Either.right(a).flatMap(f), which is f(a) by left identity. kleisli(f, Either.right) applied to a is f(a).flatMap(Either.right), which is f(a) by right identity. Those two laws are precisely the statement that of is an identity arrow in the Kleisli category.flatMap version is roughly the same line count but nests three levels deep and ends in a run of closing parens; the scope version stays flat. The crossover is two steps — at three it is not close, and at four the pyramid version starts collecting bugs in the parentheses.then is overloaded in a way map is not: it accepts both B Function(A) and Future<B> Function(A), and flattens in the second case. So then is map and flatMap fused into one method, which is convenient and is also why Future alone never teaches you the difference between the two floors of the tower.In this chapter
- two laws — associativity and identity — and what each one buys separately
- why
reducethrows on empty andfolddoes not- the property that makes parallel and chunked reduction give the same answer
NonEmptyListas a semigroup, and why FxDart's errors accumulate into one
A semigroup is a type with an associative binary operation:
combine(a, combine(b, c)) == combine(combine(a, b), c)
A monoid is a semigroup with an identity element:
combine(empty, a) == a == combine(a, empty)
That is all. int with + and 0; int with * and 1; String with + and ''; List with + and []; bool with && and true. You have used every one of them today.
void main() { // associativity: grouping does not matter print((1 + 2) + 3 == 1 + (2 + 3)); print(('a' + 'b') + 'c' == 'a' + ('b' + 'c')); // identity: the neutral element changes nothing print(0 + 7 == 7 && 7 + 0 == 7); print(''.length + 'abc'.length == 3); // subtraction is neither associative nor unital print((10 - 3) - 2 == 10 - (3 - 2));}The last line is the point of the definition: "combine two things" is not enough. Subtraction combines two ints and is useless for the jobs below.
Identity gives you the empty case. This is why Dart has two folding methods and they behave differently on an empty collection:
import 'package:fxdart/fxdart.dart'; void main() { // fold carries the identity element as a seed — total, always. print(fx(<int>[]).fold<int>(0, (a, b) => a + b)); print(fx([1, 2, 3]).fold<int>(0, (a, b) => a + b)); // reduce has no seed, so the empty case has no answer to give. try { print(fx(<int>[]).reduce((a, b) => a + b)); } catch (e) { print('reduce on empty: ${e.runtimeType}'); }}reduce requires only a semigroup and is therefore partial. fold requires a monoid — you supply empty as the seed — and is total. The exception you have hit a hundred times is a missing identity element, showing up at runtime.
Associativity gives you freedom of grouping, and that is worth more than it sounds. It means the same operation can be run:
All four give the same answer, and only associativity guarantees it.
Figure 8-1. Associativity says every bracketing of the same sequence lands on the same value. That is the licence for chunking, for parallel reduction, and for resuming a running total.
import 'package:fxdart/fxdart.dart'; void main() { final data = List.generate(12, (i) => i + 1); // Sequential. final straight = fx(data).fold(0, (a, b) => a + b); // Chunked, then the chunk results combined — legal because + // is associative and 0 is its identity. final chunked = fx(data) .chunk(5) .map((c) => fx(c).fold(0, (a, b) => a + b)) .fold(0, (a, b) => a + b); print([straight, chunked, straight == chunked]); // Order does NOT come free: subtraction disagrees with itself. final subStraight = fx(data).fold(0, (a, b) => a - b); final subChunked = fx(data) .chunk(5) .map((c) => fx(c).fold(0, (a, b) => a - b)) .fold(0, (a, b) => a - b); print([subStraight, subChunked, subStraight == subChunked]);}Associativity says grouping does not matter. Commutativity — a + b == b + a — says order does not matter, and most useful monoids do not have it. String concatenation, list append, and function composition are all associative and none is commutative.
The distinction has teeth in FxDart's async chapter: concurrent(n) evaluates elements out of order but emits them in source order, precisely so that a downstream fold only needs associativity and not commutativity. A library that delivered results in completion order would be silently demanding the stronger law from your code.
NonEmptyList, and why errors are a semigroupChapter 6 accumulated validation errors. Ask what type they accumulate into and the algebra answers before you do: you need something you can combine associatively (two failed branches concatenate), and the result of combining failures is never empty — so identity is not merely unnecessary, it would be a lie.
That is a semigroup without a monoid, and FxDart names it NonEmptyList:
import 'package:fxdart/fxdart.dart'; void main() { final a = NonEmptyList.of('name is empty'); final b = NonEmptyList.of( 'age is negative', ['age is not a number']); // Combining failures is list concatenation: associative, // and the result cannot be empty. final all = NonEmptyList.of(a.first, [...a.skip(1), ...b]); print(all.toList()); print('length: ${all.length}'); // Nel is an extension type over List, so it costs nothing at // runtime — and `orNull` is the only way in from a plain list. print(NonEmptyList.orNull(<String>[]));}Either<Nel<E>, A> therefore reads as a precise claim: if this failed, there is at least one reason, and reasons combine. A List<E> would have admitted the nonsense state "failed with zero errors" — Chapter 3's argument, applied to the error channel.
🎓 Monoids compose, which is why they are everywhere. If
AandBare monoids then so is(A, B), combining componentwise with(emptyA, emptyB)as identity — so "sum, count, and max in one pass" is a single fold over a product monoid, and an average is that fold plus a division. Functions into a monoid form a monoid ((f + g)(x) = f(x) + g(x)), and endofunctions form a monoid under composition withidentityas the unit — which is the sentence hiding inside "a monad is a monoid in the category of endofunctors":flattenis the combine,ofis the identity, and the three monad laws of Chapter 1 are these two laws in disguise.
Whenever you write a fold, you are choosing a monoid, and naming it out loud tells you whether the code is right: does it have an identity (what should the empty case return?), and is it associative (may the work be split)?
It pays hardest at scale — chunked processing, parallel aggregation, incremental totals in a database — and in API design, where "give me a seed and a combine" is the interface that lets a library batch your work without asking.
It does not pay as vocabulary in a codebase that does one reduce over ten elements. Say "sum" there.
max a semigroup on int? A monoid? What would the identity element have to be, and does Dart have it?empty is not the "obviously empty" value — that is, where a reader would guess wrong.fx(xs).fold(0, (a, b) => a + b.length) sums string lengths. Is the function you passed to fold associative? Why is that not a problem?Either accumulation and Future.wait combine independent results. Which monoid is Future.wait using, and what does it do with failures?max is associative and commutative; its identity is negative infinity, which for int does not exist in Dart — so max is a semigroup on int and a monoid only on double (double.negativeInfinity) or on int? with null as the identity. That is the honest reason reduce is the natural fit for max and fold needs an awkward seed.bool under && has identity true, not false; int under * has identity 1, not 0; and the "first non-null" monoid has identity null. The lesson is that empty is determined by the operation, never by the type — guessing from the type is how a fold ends up multiplying everything by zero.int differs from the element type String. fold in Dart is the more general catamorphism (B, A) → B, and only when B == A does the monoid question arise. It is not a problem because the sequential fold never regroups; it becomes a problem the moment you want to chunk it, at which point you must factor the operation into a genuine monoid (String → int, then sum).Future.wait uses the list monoid on results — concatenating them in argument order, with [] as identity (waiting on nothing gives an empty list). Failures are not accumulated: by default the first error wins and the rest are dropped, which is the fail-fast behaviour Chapter 6 contrasted with zipOrAccumulate. eagerError: false changes when it reports, not how many it reports.In this chapter
- the swap:
List<Either<E, A>>→Either<E, List<A>>, and why you keep needing ittraverse= map + sequence, and what the applicative contributes- fail-fast and fail-slow versions, and the honest cost of each
- the async twin, and where
traversemeetsconcurrent(n)
Validate ten rows and you have List<Either<E, Row>>. Nothing downstream wants that: the caller wants either every row, or the reasons it cannot have them. Written by hand it is the same fifteen lines every time — an accumulator, a loop, an early return.
The operation has a name, sequence, and its generalisation — map first, then sequence — is traverse:
sequence : List<F<A>> → F<List<A>>traverse : List<A> × (A → F<B>) → F<List<B>>
Read it as swapping the two structures. The list stays a list, the effect stays an effect; which one is on the outside changes.
Figure 9-1. Every element carries its own little effect; after the swap, one effect carries the whole list. The values are unchanged — only the nesting is.
import 'package:fxdart/fxdart.dart'; Either<String, int> parsePort(String s) { final n = int.tryParse(s); if (n == null) return Either.left('not a number: $s'); if (n < 1024) return Either.left('privileged: $n'); return Either.right(n);} void main() { // traverse: map each element to an Either, then swap. print(fx(['8080', '9000']).map(parsePort).sequence()); print(fx(['8080', 'x', '80']).map(parsePort).sequence());}One value out, and it is the value the rest of the program wants: Right with every port, or Left with the first reason there is no list at all.
map alone cannot do this. Mapping over the list gives you effects inside, and nothing about map can move one out. To build F<List<A>> you must combine the element effects with each other — that is map2 from Chapter 6, applied repeatedly:
sequence([a, b, c]) = map2(a, map2(b, map2(c, of([]), cons), cons), cons)
Which immediately explains the two behaviours you can get. The combining operation is the applicative's, so the applicative you traverse with decides the failure policy:
Either's fail-fast applicative → stop at the first Left.Left.Same traversal, different algebra, different report. FxDart exposes both:
import 'package:fxdart/fxdart.dart'; Either<String, int> parsePort(String s) { final n = int.tryParse(s); if (n == null) return Either.left('not a number: $s'); if (n < 1024) return Either.left('privileged: $n'); return Either.right(n);} void main() { final raw = ['8080', 'x', '80', '9000']; // Fail fast: the first reason, and nothing after it ran. print(fx(raw).map(parsePort).sequence()); // Fail slow: every reason, in order. print(fx(raw).map(parsePort).flattenOrAccumulate()); // And the map-and-swap in one step, with the accumulating // applicative doing the combining. print(mapOrAccumulate( (r, String s) => r.bind(parsePort(s)), raw));}There is a third thing you might want — keep the good rows and report the bad ones — and that is not a traversal at all, because the result is two lists rather than one effect. It has its own name:
import 'package:fxdart/fxdart.dart'; Either<String, int> parsePort(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad: $s') : Either.right(n);} void main() { final results = ['8080', 'x', '9000'].map(parsePort).toList(); final (bad, good) = separateEither(results); print('kept: $good'); print('dropped: $bad'); // …or take just one side. print(rights(results)); print(lefts(results));}Choosing between them is a product decision, not a technical one: an import tool wants separateEither, a config loader wants flattenOrAccumulate, an API handler wants sequenceEither.
Swap Future in for Either and the same operation appears wearing Dart's own clothes: Future.wait is sequence for futures. Which means the interesting version is the one that traverses and bounds the work:
import 'package:fxdart/fxdart.dart'; Future<int> fetchSize(String url) async { await Future.delayed(const Duration(milliseconds: 20)); return url.length;} void main() async { final urls = ['a.com', 'bb.com', 'ccc.com', 'dddd.com']; // Sequence with unbounded concurrency: Future.wait. print(await Future.wait(urls.map(fetchSize))); // Traverse with *bounded* concurrency: three in flight, // results still in source order. final bounded = fx(urls).toAsync().mapConcurrent(3, fetchSize); print(await bounded.toList());}Future.wait is the applicative traversal with no throttle: it starts everything. mapConcurrent(n) is the same traversal with a limit, which is what you actually want against a rate-limited API. Chapter 13 explains the back-channel that makes the limit real rather than advisory.
🎓 Traverse is more general than lists. The full signature is
traverse : T<A> × (A → F<B>) → F<T<B>>for any traversable containerTand any applicativeF— trees, maps, andOptionare traversable too. It has two laws (identity and composition, like the functor's) and one famous corollary:traversewith the identity applicative is justmap, and with the constant applicative it isfold.map,foldandtraverseare three faces of one operation — which is a beautiful result, and requires higher-kinded types to state even once. That is Chapter 10's subject, and the reason FxDart ships four concrete traversals instead of one generic one.
Count the versions in the code above: sequenceEither, flattenOrAccumulate, mapOrAccumulate, separateEither — plus sequenceEitherAsync, flattenOrAccumulateAsync, and mapOrAccumulateAsync for chains that are asynchronous. Seven functions where a language with higher-kinded types writes one.
That is not incompetence, it is the language's ceiling, and it has a real cost to you: when FxDart adds a new effect type, none of your existing traversals work with it until someone writes the seventh, eighth and ninth variants by hand.
Any boundary where a collection of independent, fallible things must become one decision: parsing a config file, validating an import, loading N records, fanning out to N services. If you have written for (final x in xs) { final r = f(x); if (r.isLeft) return r; out.add(...); } more than twice, that is a traversal and you should say so.
Skip it when the collection is one element (just use the Either directly), when you need partial success semantics (that is separateEither), or when the loop genuinely does something per-element that is not a pure map — a traversal that hides a side effect is worse than the loop it replaced.
sequence on an empty list — for Either, and for Future? Which law of Chapter 8 decides the answer?traverse(xs, f) and xs.map(f) followed by sequence give the same result. Which is cheaper in Dart, and why does FxDart still ship both spellings?Either<E, List<A>> and want List<Either<E, A>> — the swap in the other direction. Is that always possible? Try it for a Left.Future.wait starts every future immediately. Write down two situations where that is exactly right, and two where mapConcurrent(n) is the only correct choice.Right([]) and Future.value([]) — the empty list wrapped in the applicative's pure. The identity element of Chapter 8's list monoid is [], and sequence of nothing must produce the identity; anything else would break the composition of two traversals over concatenated inputs.traverse avoids building the intermediate List<F<B>>, which matters at scale but not at ten elements. FxDart ships both because the two-step form composes into an existing lazy chain (.map(f).sequenceEither()), while the fused form is the one you want when the source is already materialised.Left(e): it maps to [Left(e)]? Or to []? Both are defensible, which is the tell that this direction is not a traversal — there is no law forcing the answer. The general swap F<T<A>> → T<F<A>> is called a distributive law and exists only for particular pairs of structures.Future.wait over a 100k-element list opens 100k sockets, and the failure mode is your process, not theirs.In this chapter
- kinds: the types of types, and where
Listsits without its argument- the exact Dart declaration that does not compile, and why no trick recovers it
- what Scala, Haskell and Kotlin's Arrow do instead
- the bill FxDart pays, counted in functions
Values have types. Types have kinds.
int is a complete type: you can declare a variable of it. Its kind is written *. List on its own is not a complete type — List<int> is. List is a function from types to types, and its kind is written * → *.
| Thing | Kind | Complete? |
|---|---|---|
int, String, List<int> | * | yes |
List, Future, Fx | * → * | needs one argument |
Either, Map | * → * → * | needs two |
Chapters 5 to 9 were all about types of kind * → *: functor, applicative, monad and traversable are properties of a type constructor, not of a type. List<int> is not a monad; List is.
That sentence is the whole chapter. To write the interface down, you need a type parameter that is itself of kind * → * — a higher-kinded type.
// Does not compile. Dart type parameters are always kind `*`,// so `M` is a complete type and cannot take an argument.abstract class Monad<M> { M<A> of<A>(A value); M<B> flatMap<A, B>(M<A> box, M<B> Function(A) f);}
The error is not about syntax. Dart's type variables range over complete types only, so M<A> is meaningless in the same way 3(4) is meaningless. It is a deliberate design point — first-order generics keep inference decidable and error messages readable — and it is a ceiling, not a bug to be worked around.
Figure 10-1. Every floor of the tower is a statement about a type constructor. Dart can talk about the floors one type at a time; the beam that would carry all of them at once needs a kind the language does not have.
The workarounds all fail in the same way — they compile, and then they lie:
// The "defunctionalisation" trick: erase the constructor to a// marker, then cast it back. It type-checks. It is not typed.abstract class Kind<F, A> {} class ListK<A> implements Kind<ListK<Never>, A> { ListK(this.value); final List<A> value;} abstract class Monad<F> { Kind<F, A> of<A>(A value); Kind<F, B> flatMap<A, B>( Kind<F, A> fa, Kind<F, B> Function(A) f);} class ListMonad implements Monad<ListK<Never>> { @override Kind<ListK<Never>, A> of<A>(A value) => ListK([value]); @override Kind<ListK<Never>, B> flatMap<A, B>( Kind<ListK<Never>, A> fa, Kind<ListK<Never>, B> Function(A) f, ) { // The cast is the whole problem: nothing checks it. final list = (fa as ListK<A>).value; return ListK(list .expand((a) => (f(a) as ListK<B>).value) .toList()); }} void main() { final m = ListMonad(); final r = m.flatMap<int, int>( m.of(3), (a) => ListK([a, a * 10])); print((r as ListK<int>).value);}It works, and look at the price: three casts, a Never phantom, and a return type — Kind<ListK<Never>, int> — that no caller wants. Every use site casts back to the real type, so the abstraction hands you generic code whose type errors surface at runtime. Arrow's early versions did exactly this, in Kotlin, and then abandoned it. FxDart's ARROW_MIGRATION_BLOCKER.md records the same conclusion for Dart.
class Monad m where (>>=) :: m a → (a → m b) → m b is ordinary code, and every instance is checked against it.F[_]), which is why Cats can define Traverse[F[_]] once and get every combinator for free.Kind encoding above. Arrow 2.x deleted it: the ergonomics were bad enough that the team chose concrete types plus context receivers and a Raise scope instead — the design FxDart ports.🎓 What is actually lost. Not expressiveness — every program you can write with an HKT abstraction can be written without it, by hand, per type. What is lost is abstraction over the abstraction: one
traverseinstead of seven, onesequence, one set of laws to test once. In a language with HKTs a new effect type arrives already equipped with the whole library; in Dart it arrives empty, and someone has to fill it in. The difference is library maintenance cost, not program capability — which is precisely why it is a reasonable language design choice and still an irritating one.
Every abstraction in Part II that a higher-kinded language writes once, FxDart writes per type. Concretely, for one operation — traversal — the library ships:
import 'package:fxdart/fxdart.dart'; void main() { final xs = <Either<String, int>>[ Either.right(1), Either.left('bad'), Either.right(3), ]; // Four spellings of "swap the structures", because there is no // way to write one that works for every effect type. print(sequenceEither(xs)); print(flattenOrAccumulate(xs)); print(separateEither(xs)); print(fx(xs).sequence()); // …plus sequenceEitherAsync, flattenOrAccumulateAsync, // mapOrAccumulateAsync for the async chain.}And the flip side, so the trade is honest: because these are concrete, they are fast and their types are exact. sequenceEither returns Either<L, List<R>> — not Kind<F, List<R>>, not a wrapper you have to unpick. Dart's inference works, the editor completes, the errors point at your code. A generic version in the Kind encoding would return something no reader can use without a cast.
Mostly it does not — until you go looking for the generic combinator that "obviously" should exist. This chapter is the answer to that search: it does not exist, it cannot exist, and the concrete version is over there.
It matters when you design a library. If you catch yourself trying to abstract over "any container with a map", stop: in Dart, write the two or three concrete versions and name them well. The abstraction you are reaching for will cost more than it returns.
It also matters when you read Haskell or Scala for ideas — which is worth doing. Just translate structurally, not literally: their one-line generic definitions become your concrete methods, and the laws survive the translation even when the polymorphism does not.
Map? Of Map<String, dynamic>? Of a hypothetical Traverse interface?Kind encoding above to Either and write flatMap for it. How many casts do you need, and where would a wrong one blow up?Fx<T> has map, flatMap and sequence-style terminals. Can you write a function that accepts "any FxDart type with a map" without using dynamic or a common supertype? Explain the answer in terms of kinds.T extends Comparable<T>. Why is that not a counterexample to this chapter?Map is * → * → * (two arguments); Map<String, dynamic> is *; Traverse would be (* → *) → * — it takes a type constructor and produces a type. That last kind is exactly what Dart cannot spell, and the parenthesis is where the language stops.Kind<F, A> into EitherK<E, A>, one on the result of f. They blow up at runtime, when someone passes a ListK into an Either monad instance: the type system was never watching, since both erase to Kind<F, _>.* → * ("some F such that F<A> has a map"), and Dart parameters are all kind *. The available workarounds are exactly the three bad ones: dynamic, a shared supertype (which Fx and Either do not have and should not), or the Kind cast encoding.T extends Comparable<T> constrains a complete type — T is still kind *, and Comparable<T> is a bound on it, not a higher-kinded parameter. F-bounded polymorphism is a different feature that solves a different problem, and it is a good illustration that "generic enough for most code" and "higher-kinded" are separate axes.In this chapter
- descriptions versus executions, and which operators are which
- the cost model: work is proportional to what you consume, not what you write
- why laziness cannot change any law from Part II
- the two real hazards: effects in a pipeline, and a source that can only be read once
Write a chain and nothing happens:
import 'package:fxdart/fxdart.dart'; void main() { var calls = 0; final chain = fx([1, 2, 3, 4, 5]).map((n) { calls++; return n * 2; }); print('after building the chain: $calls calls'); print(chain.toList()); print('after consuming it: $calls calls');}map, filter, take, chunk, zip are lazy — each returns a new description with one more stage. toList, each, fold, first, sum are terminal — they pull values through, and only then does anything run.
The rule for telling them apart is the return type, and it never lies: if you get another Fx back, nothing happened yet.
Figure 11-1. The dashed chain is a plan: stages wired together, no values in motion. The terminal operator is what pulls, and a value travels the whole chain before the next one starts.
Because the terminal decides how much to pull, work is proportional to what you consume:
import 'package:fxdart/fxdart.dart'; void main() { var evaluated = 0; final result = fx(range(1, 1000000)) .map((n) { evaluated++; return n * n; }) .filter((n) => n.isOdd) .take(3) .toList(); print(result); print('elements evaluated: $evaluated of 999,999');}Five evaluations for three results out of a million candidates. The eager version of that program builds a million-element list, then filters it into another list, then throws all but three away.
This is the difference that shows up in FxDart's own benchmark suite as the cases where the pipeline beats a hand-written loop — the loop is usually faster per element, but the lazy chain refuses to do the work at all. Chapter 14 puts numbers on both directions.
Two more consequences fall out of the same model:
first, any, find stop the pull as soon as they have an answer, with no special support from the stages upstream.import 'package:fxdart/fxdart.dart'; void main() { // An endless cycle, consumed finitely. print(fx([1, 2, 3]).cycle().take(7).toList()); // `some` stops pulling at the first match. var checked = 0; final found = fx(range(1, 1000)).some((n) { checked++; return n > 4; }); print([found, checked]);}This is the part worth being explicit about, because it is what makes laziness safe to rely on. Every law in Part II is an equation between values: the functor law says m.map(f).map(g) equals m.map(g ∘ f), and equality of two pipelines means they produce the same elements in the same order.
When does evaluation happen is not part of that equation. So:
map stages is legal (functor composition law);filter before a map is legal if the predicate does not depend on the mapping — a genuine precondition, not a laziness issue;That last clause is the whole catch, and it is Chapter 2's clause. In a pure pipeline, laziness is invisible except in the bill. In an impure one, it is visible everywhere, because when an effect happens is exactly what an effect lets you observe.
import 'package:fxdart/fxdart.dart'; void main() { final log = <String>[]; // Built, never consumed: the effect never happens. final unused = fx([1, 2, 3]).map((n) { log.add('mapped $n'); return n; }); print('log after building: $log'); // Same chain, consumed twice: the effect happens twice. final used = fx([1, 2]).map((n) { log.add('mapped $n'); return n; }); used.toList(); used.toList(); print('log after two pulls: $log'); print(unused.take(0).toList());}Both surprises are the same surprise: a lazy chain is a recipe, and recipes can be cooked zero times or twice.
🎓 Lazy, strict, and what Haskell means by it. Haskell is lazy by default at the level of every expression: a value is a thunk until forced, which gives you infinite data structures and
whereclauses that cost nothing when unused — and space leaks when a thunk chain grows unforced. Dart is strict; a lazy pipeline is laziness re-created at the level of sequences, and the mechanism is a pull protocol rather than thunks. The practical difference: you get the short-circuit and streaming benefits, you do not get (or have to debug) unbounded thunk accumulation, andfx(xs).map(f)composed twice is a plan, whilelet y = f xis already a thunk.
A pipeline over a List can be pulled repeatedly — a list is re-readable. A pipeline over a source that is consumed as it is read cannot:
import 'package:fxdart/fxdart.dart'; Iterable<int> readOnce() sync* { // A generator: iterating it again starts over, but a *stream* // or a socket would not — that is the shape to watch for. yield 1; yield 2;} void main() { final chain = fx(readOnce()).map((n) => n * 10); print(chain.toList()); print(chain.toList()); // fine here — the generator restarts // The rule that always holds: if you need the values twice, // materialise once and re-read the list. final materialised = chain.toList(); print([materialised.length, materialised.first]);}The guidance is short: consume once, or materialise. If a chain is used by two consumers, call toList() and share the list, or use fork/tee, which exist precisely to split a pull into several without re-running the source.
Laziness pays whenever the pipeline could produce more than you need: taking the top N, searching for the first match, streaming a file you will stop reading, composing filters whose combined selectivity is high. It also pays for memory — one element in flight instead of one list per stage.
It costs when everything gets consumed anyway and the source is small: then the per-element protocol is overhead against a plain loop, and Chapter 14 measures exactly how much. And it costs in debuggability — a stack trace inside a lazy chain shows iterator frames, not your pipeline, which is the price of the indirection.
fx(xs).map(f).toList() and xs.map(f).toList() do the same work. At what point does the FxDart version start to win, and which operator in the chain is what causes it?peek(print) before take(2) over a ten-element source. How many lines print, and why?fx(range(1, 1000000)).map(expensive).first — how many times does expensive run? What if .first is replaced by .last?take, first, some, find, or a filter that rejects most elements before an expensive map. With no such stage the two do identical work, and the eager version has less per-element machinery.take(2) stops pulling after the second element, so peek is never asked about elements three onwards — the pull is what drives the upstream, and it stopped.final xs = chain.toList(); then use xs twice. Fix two: use fork/tee to split one pull into two consumers, so the source is still read once..first — one pull satisfies it. With .last, all 999,999 times: last has to reach the end, so there is nothing left to skip. Same chain, same laziness, opposite cost, decided entirely by the terminal.In this chapter
- the duality:
IteratorandStreamdiffer in who makes the call- what falls out of it — backpressure, cancellation, and time
- why FxDart's async model is a pull protocol and not a
Stream- the bridges, and how to pick a side for a given problem
pull: consumer asks → producer answers iterator.moveNext()push: producer calls → consumer receives stream.listen(onData)
That is the entire difference, and everything else in this chapter is a consequence of it. A pull source is a function you call; a push source is a callback you register.
Pull (Iterable, FxAsyncIterable) | Push (Stream) | |
|---|---|---|
| Drives the pace | consumer | producer |
| Backpressure | free — just do not ask | must be arranged |
| Stop early | stop pulling | cancel a subscription |
| Time | not modelled | inherent |
| Natural fit | collections, files, paged APIs | UI events, sockets, timers |
Figure 12-1. Same values, opposite arrows. In a pull chain the request travels upstream and the value comes back; in a push chain the value travels downstream and nobody upstream is waiting for permission.
Formally these are duals — one is the mirror of the other with the arrows reversed — which is why the operator vocabularies look so similar (map, filter, take, scan on both sides) and the failure modes are opposites.
If the consumer is slower than the producer, something has to give.
In a pull chain nothing gives, because the consumer's next call is the clock. A slow consumer simply asks less often, and the producer is idle in between:
import 'package:fxdart/fxdart.dart'; void main() async { var produced = 0; final source = fx(range(1, 1000)).map((n) { produced++; return n; }).toAsync(); // The consumer takes three and stops asking. final taken = await source.take(3).toList(); print(taken); print('produced: $produced'); // not 999}In a push chain the producer keeps going regardless. Dart's Stream handles this with pause/resume for the sources that support it, and buffers for the ones that do not — which turns a rate mismatch into memory growth rather than a compile error. The classic bug is a broadcast stream with a slow listener: the queue grows, latency grows, and nothing in the types said so.
StreamFxDart's async sequences are FxAsyncIterable — a pull protocol — because its signature feature needs the consumer to be in charge.
concurrent(n) asks the upstream to evaluate n elements at once. That request has to travel backwards, from the consumer towards the source, which is exactly the direction a pull protocol already has an arrow for. FxDart passes a marker through iterator.next(concurrent): the consumer says "give me the next one, and by the way, run n of these in parallel", and every stage upstream can honour or forward it.
There is no way to express that on a Stream. A push source is already running; the consumer can only ask it to pause, not to go wider. You would have to invent a side-channel — which is what the various parallel operators in Rx-style libraries are — and then reconcile it with buffering and ordering by hand.
import 'package:fxdart/fxdart.dart'; Future<String> fetch(String id) async { await Future.delayed(const Duration(milliseconds: 40)); return 'data-$id';} void main() async { final ids = ['a', 'b', 'c', 'd', 'e', 'f']; final sw = Stopwatch()..start(); // One at a time: six 40ms waits, serially. await fx(ids).toAsync().map(fetch).toList(); final serial = sw.elapsedMilliseconds; sw.reset(); // Three at a time, results still in source order. final out = await fx(ids).toAsync().map(fetch).concurrent(3).toList(); final concurrent = sw.elapsedMilliseconds; print(out.first); print('serial ~${serial}ms, concurrent(3) ~${concurrent}ms');}The pull protocol is what makes the second number roughly a third of the first without buffering, without losing order, and without a second API. Chapter 13 is about the guarantees that come with it.
Being a pull library does not mean ignoring push. Real programs have both — a UI event is genuinely a push, a database page is genuinely a pull — so FxDart crosses in both directions:
import 'package:fxdart/fxdart.dart'; void main() async { // push → pull: a Stream becomes a pull chain. final ticks = Stream.fromIterable([1, 2, 3, 4, 5]); final doubled = await fxStream(ticks).map((n) => n * 2).take(3).toList(); print(doubled); // pull → push: a chain becomes a Stream for the framework. final asStream = fx([1, 2, 3]).toAsync().map((n) => n + 10).toStream(); print(await asStream.toList());}FxDart also ships an explicitly push-shaped layer — fxEvents, with Rx-style operators over plain Streams — for the problems that genuinely are about time and broadcast:
import 'package:fxdart/fxdart.dart'; void main() async { final clicks = Stream.fromIterable(['a', 'a', 'b', 'b', 'b', 'c']); // Push-side operators: same names, producer-driven semantics. final out = await fxEvents(clicks) .map((s) => s.toUpperCase()) .where((s) => s != 'B') .toList(); print(out);}The rule of thumb for choosing: who decides when the next value exists? If the answer is "the outside world", you are on the push side and should stay there. If the answer is "whoever consumes it", pull is simpler and gives you backpressure for free.
🎓 Dual, precisely. An iterator is
() → Option<(A, Iterator<A>)>— the consumer applies it. An observer is((A) → Unit) → Unit— the producer applies your callback. Turn every arrow in one around and you get the other; that is the sense in which Rx was described by its designers as "the dual ofIEnumerable". The duality also predicts which operators are hard on each side:zipis easy on pull (ask both, wait for both) and needs buffering on push, whiledebounceis natural on push (it is about elapsed time) and meaningless on pull, where nothing happens between requests.
Pull, when the data is there and you decide the pace: collections, files, paged HTTP, database cursors, anything you might stop reading early, anything where "N at a time" is a policy you want to state.
Push, when the data arrives whether or not you are ready: user input, websockets, sensors, timers, and anywhere several consumers must see the same event. Trying to model a click stream as a pull sequence means writing a buffer by hand, badly.
The mistake to avoid is converting to the other side just to reuse a familiar operator name. Bridge when the problem changes shape, not when the vocabulary feels nicer.
take(3) on a pull chain stops the producer. What is the equivalent on a Stream, and what happens to values that were already in flight?debounce unavailable on a pull chain? Describe what it would even mean, and which part is incoherent.Stream has asBroadcastStream; pull chains have fork/tee. Both let two consumers see one source. What is the essential difference in what happens when one consumer is slow?subscription.cancel(). Values already emitted are gone, and a value the producer is mid-way through computing is finished and discarded — the producer was never waiting for permission, so cancellation is a request, not a barrier. On a pull chain, "stop" is simply the absence of the next call, so nothing is in flight to discard.debounce means "emit only if nothing else arrived within X". On a pull chain nothing arrives on its own: the next value exists exactly when you ask for it, so the window would always be empty and the operator would degrade into map. It is the clearest example of an operator that is about the producer's timing, which only push has.FxAsyncIterable that fetches a page when the consumer exhausts the current one. Push: a Stream that fetches pages as fast as it can. "Stop after the first match" costs exactly one request in the pull model if the match is on page one; the push version has usually already fetched several pages by then — the difference is unbounded and grows with latency.asBroadcastStream gives every listener the same events at the producer's pace: a slow listener either buffers or drops, and it cannot slow the producer down. fork/tee split a pull, so the shared source advances only when both consumers have asked — the slow consumer holds the fast one back, which is backpressure working as designed, and is the right default when correctness matters more than liveness.In this chapter
- the guarantee: when, not what — and why that makes it composable
- the back-channel that carries "go n wide" upstream
- order preservation, and the cost of giving it up
- the two ways to be wrong: sharing state, and unbounded fan-out
import 'package:fxdart/fxdart.dart'; Future<int> slowSquare(int n) async { await Future.delayed(const Duration(milliseconds: 40)); return n * n;} void main() async { final input = [1, 2, 3, 4, 5, 6]; final sw = Stopwatch()..start(); final serial = await fx(input).toAsync().map(slowSquare).toList(); final serialMs = sw.elapsedMilliseconds; sw.reset(); final wide = await fx(input) .toAsync() .map(slowSquare) .concurrent(3) .toList(); final wideMs = sw.elapsedMilliseconds; print(serial); print(wide); print('same result: ${serial.toString() == wide.toString()}'); print('serial ~${serialMs}ms, concurrent(3) ~${wideMs}ms');}Two identical lists, one of them produced in a third of the time. That is the claim concurrent(n) makes, and it is worth stating exactly:
concurrent(n)changes when elements are computed. It does not change which elements are computed, what they compute to, or what order they arrive in.
Everything downstream — folds, filters, the caller — cannot tell the difference except by looking at a clock. Concurrency is added as an effect on evaluation, not as a different program.
That is why it composes. Chapter 8's associativity is enough for a downstream fold, because order is preserved; Chapter 6's independence is what makes the overlap legal in the first place; and Chapter 2's purity is what makes it safe. Each part of the tower shows up as a precondition here.
Chapter 12 said a pull protocol has an arrow pointing upstream. This is what FxDart sends along it.
An ordinary pull is "give me the next element". FxDart's async iterator takes an argument: iterator.next(concurrent), where concurrent is a marker carrying a width. A stage that receives it may:
map cannot parallelise anything by itself, so it passes the request further up.The request therefore travels from the consumer to whichever stage can actually widen, and the values come back down in order.
Figure 13-1. concurrent(3) is not a buffer in the middle of the chain — it is a message that travels upstream until something can act on it. Three elements are in flight; the consumer still receives 1, 2, 3.
This is also why the operator is placed after the expensive stage in the chain and still affects it: the marker moves up. map(fetch).concurrent(3) reads as "give me these fetched, three at a time", which is exactly what it does. mapConcurrent(3, fetch) is the same thing pre-combined.
Preserving order is not free: if element 2 finishes before element 1, its result waits. In exchange you get a sequence that is equal to the serial one, which is what lets you drop concurrent(n) into an existing pipeline without re-reading the rest of it.
When you genuinely do not care, ask for completion order and get the results sooner:
import 'package:fxdart/fxdart.dart'; Future<String> job(String name, int ms) async { await Future.delayed(Duration(milliseconds: ms)); return name;} void main() async { final jobs = [('slow', 90), ('quick', 10), ('mid', 45)]; // Source order: 'slow' first, however long it takes. final ordered = await fx(jobs) .toAsync() .map((j) => job(j.$1, j.$2)) .concurrent(3) .toList(); print(ordered); // Completion order: whoever finishes first. final asDone = await fx(jobs) .toAsync() .map((j) => job(j.$1, j.$2)) .concurrentPool(3) .toList(); print(asDone);}concurrentPool is the honest name for "I am trading determinism for latency". Use it when each result is handled independently — writing to a sink, updating a UI — and never when a downstream step assumes positional alignment with the input.
🎓 Concurrency is not parallelism, and Dart makes that literal. All of this happens on one isolate: a single thread interleaving continuations while IO waits. Nothing above makes CPU-bound code faster — six 40ms computations take 240ms with or without
concurrent, because there is no second core in play. What overlaps is waiting. For genuine parallelism you need isolates, which cannot share mutable state and therefore make Chapter 2's purity a mechanical requirement rather than a discipline. The vocabulary is worth keeping straight:concurrent(n)bounds in-flight work; isolates buy cores.
Sharing mutable state across callbacks. With concurrent(n), n callbacks are in flight at once and their interleaving is not specified. A counter incremented inside map is fine on one isolate (no preemption between statements), but a read-modify-write across an await is not:
import 'package:fxdart/fxdart.dart'; void main() async { var balance = 100; // Each callback reads, awaits, then writes — the read is stale // by the time the write happens. await fx([1, 2, 3]) .toAsync() .map((n) async { final read = balance; await Future.delayed(const Duration(milliseconds: 10)); balance = read - 10; return n; }) .concurrent(3) .toList(); print('balance: $balance (serial answer would be 70)');}The fix is not a lock; it is not writing the code. Return values and fold them downstream — where order is guaranteed — instead of mutating shared state inside a concurrent stage.
Unbounded fan-out. Future.wait(items.map(fetch)) starts everything: fine for ten items, an outage for ten thousand. The whole point of a width parameter is that the width is yours to choose, and the right number comes from the remote side's limits, not from the length of your list.
Any pipeline whose per-element work is IO: HTTP fetches, file reads, database round trips. The gain is roughly the width, up to the point where the remote side becomes the bottleneck — and the measurement in the first listing is the one to repeat against your own service rather than trusting the ratio.
It does nothing for CPU-bound work on one isolate, and it actively hurts when the source is cheap and short: three extra futures to compute six squares is overhead. As with laziness, the model tells you where the win is — waiting, not computing.
concurrent(3) took ~90ms. Predict the time at concurrent(6) and at concurrent(2), then run it.concurrent(n) placed after map(fetch) affect fetch at all? Answer in terms of the direction of the request..chunk(10) follows a concurrentPool(4). What breaks, and would concurrent(4) have the same problem?concurrent(6) should be about one round — ~45ms — because all six waits overlap. concurrent(2) takes three rounds, ~130ms. The pattern is ceil(items / n) × latency, which is the formula worth remembering when choosing a width.concurrent(3) does not process the values arriving into it; it asks its source for three at a time, and that source is the map(fetch) stage, which starts three fetches. In a push model there would be nothing to ask — the fetches would already be running..map((n) async { …; return -10; }).concurrent(3) then fold(100, (a, d) => a + d). State moved out of the concurrent region into the ordered one — which is the general fix, and the reason fold runs after the pipeline rather than inside it.chunk will happily group whatever arrives — but the chunks no longer correspond to input positions, so any code that assumes "chunk 0 is the first ten inputs" is now wrong. With concurrent(4) the correspondence holds, because order is preserved. This is the concrete cost of trading determinism for latency.In this chapter
- the measured shape of the trade, across 53 real tasks
- the two mechanisms that make a pipeline slower, and the one that makes it faster
- why "allocations" is the answer to almost every performance question here
- how to decide, for your code, without trusting anyone's ratio
FxDart ships a benchmark suite that compares each of its 53 side-by-side examples against a hand-written Dart version of the same task, AOT-compiled, median of repeated runs. At the largest scale of each case:
| Outcome at the headline scale | Cases |
|---|---|
| Tie (within 5% or 0.6ms) | 38 |
| Hand-written Dart faster | 12 |
| FxDart faster | 3 |
Median ratio across all 53: 1.06× — the pipeline is about six percent slower, typically, and inside the noise band more often than not.
The extremes are more interesting than the median:
| Case | Ratio | What it means |
|---|---|---|
top-expenses | 0.27× | pipeline nearly 4× faster |
price-drop-detection | 0.52× | pipeline 2× faster |
smoothed-zone-changes | 2.23× | pipeline 2.2× slower |
anomaly-context | 1.76× | pipeline 1.8× slower |
Same library, same machine, an eight-fold spread between best and worst. Any sentence of the form "FP is n times slower in Dart" is therefore false; the number depends entirely on which of three mechanisms dominates.
A hand-written loop reads an element and applies your code inline. A pipeline sends every element through an iterator per stage: a virtual moveNext, a current getter, a closure call. Dart's AOT compiler inlines a lot of that, but not across a polymorphic iterator boundary, and the cost is paid per element per stage.
That is why the losers above are the chapters with many cheap stages over uniform data: windowed smoothing, adjacent comparisons, small numeric work. The per-element overhead is fixed, the useful work per element is tiny, so the ratio is bad.
import 'package:fxdart/fxdart.dart'; void main() { final data = List.generate(200000, (i) => i); final sw = Stopwatch()..start(); var loop = 0; for (final n in data) { if (n.isEven) loop += n * 2; } final loopMs = sw.elapsedMicroseconds; sw.reset(); final piped = fx(data).filter((n) => n.isEven).map((n) => n * 2).sum(); final pipeMs = sw.elapsedMicroseconds; print([loop, piped, loop == piped]); print('loop ${loopMs}us, pipeline ${pipeMs}us'); // In the browser this is JIT-compiled JS, so treat the ratio // as indicative; the book's table is AOT.}Most large differences in the suite are not CPU, they are garbage. The hand-written version of a grouping task builds intermediate Lists and Maps; the pipeline version may build none, or may build a record per element. Which side allocates more depends on the task, and allocation dominates the timing whenever it differs.
This is the single most useful diagnostic: count the allocations on both sides. If they are equal, the two versions will be within noise of each other. If one side materialises an intermediate collection the other never builds, that side loses regardless of how tight its loop is.
Chapter 11's cost model, showing up on the scoreboard. top-expenses is 3.7× faster in the pipeline version not because the pipeline is quick, but because it never sorts the whole list: it takes what it needs and stops, while the native version sorts 10,000 elements to read the top five.
Every one of the three FxDart wins is this mechanism. When a task has the shape "most of this data does not matter", laziness beats a faster loop that does all the work anyway.
Figure 14-1. Three independent forces. Indirection is a fixed tax per element per stage; allocation is whichever side builds more intermediates; refused work is the pipeline's rebate when a terminal stops early.
The suite's own rules, worth copying:
🎓 Big-O is unchanged; constants are not. None of this affects asymptotic complexity: a lazy
filter+map+foldis O(n) exactly like the loop, andtop-expensesis faster because laziness changes the algorithm (partial selection instead of a full sort), not because the constant improved. When you find a large win, ask which one it was — a constant-factor win of 30% is a tuning result, an asymptotic win is a design result, and only the second one survives a change of input size.
take, first, find, or early-exiting any over a large source is a reason to expect the pipeline to win.Write the for loop when the code is in a hot path, the stages are cheap, and the source is fully consumed — that is precisely the losing shape. Write it too when the operation genuinely is imperative: mutating a buffer, filling a pre-sized list, driving an index-based algorithm. Chapter 22 collects the rest of these cases; this chapter's contribution is that you can now predict the answer instead of guessing, and check it in ten minutes.
.first. Which of the three mechanisms dominates, and what is the expected ratio against a loop that does the same job?smoothed-zone-changes is 2.2× slower as a pipeline. Before looking at it, predict which two features of the task cause that, based on this chapter..first pulls one element through five stages, so the pipeline does roughly five closure calls of work while the loop version — if written naively — processes the whole list. The expected ratio is enormous and in the pipeline's favour; if the loop also breaks early, the two converge to a tie plus the pipeline's fixed per-stage overhead.In this chapter
- the mechanism: what
r.bindactually does when it fails- delimited continuations versus monadic desugaring, and why Dart forced the choice
- the leak rule — the one way to misuse a scope, and how the library catches it
- what you gain and lose against
flatMapchains
either isChapter 7 used the scope and did not open it. Here is the shape:
import 'package:fxdart/fxdart.dart'; Either<String, int> half(int n) => n.isEven ? Either.right(n ~/ 2) : Either.left('odd: $n'); void main() { final result = either<String, int>((r) { final a = r.bind(half(20)); // 10 final b = r.bind(half(a)); // 5 final c = r.bind(half(b)); // odd → exits here return c * 100; // never reached }); print(result);}either runs your block with a Raise<E> object. r.bind looks at an Either: on a Right it returns the value, on a Left it abandons the block entirely and makes either return that Left. No pyramid, no flatMap, and the block reads top to bottom.
The mechanism is a control-flow escape: raise throws a private marker that either catches at the boundary and converts into a Left. Because the throw and the catch are both inside the library, the escape is delimited — it can only ever travel as far as the enclosing either, and no further.
Figure 15-1. Every bind is a possible exit, and every exit lands at the same place: the boundary of the scope that created r. That boundary is what turns a control-flow jump back into an ordinary value.
Scala's for and Haskell's do are rewrites: the compiler turns the block into flatMap calls before it type-checks. That works for any monad, and needs higher-kinded types to say "any monad" — which Chapter 10 explained Dart does not have.
FxDart's scope is not a rewrite. Nothing is transformed; a real object is passed in, and control leaves the block by a mechanism the language already has. The trade is exact:
Desugaring (do, for) | Scope (either, Arrow's Raise) | |
|---|---|---|
| Works for | any monad the types can name | the effects the library wrote |
| Needs | higher-kinded types | nothing special |
| Failure exit | returning a short-circuited value | non-local jump, caught at the boundary |
Composes with async | needs a transformer | naturally — eitherAsync |
| Extensible by you | yes, by defining a monad | no |
The last two rows are the reason the choice is defensible rather than merely forced. A transformer tower (EitherT[Future, E, A]) is the general answer, and it is genuinely hard to read; the scope handles the one combination people actually write — failure inside async — with no new type at all:
import 'package:fxdart/fxdart.dart'; Future<Either<String, int>> lookup(String key) async { await Future.delayed(const Duration(milliseconds: 10)); return key == 'port' ? Either.right(8080) : Either.left('missing: $key');} void main() async { final ok = await eitherAsync<String, String>((r) async { final port = r.bind(await lookup('port')); final host = r.bind(await lookup('port')); return 'http://$host:$port'; }); print(ok); final bad = await eitherAsync<String, String>((r) async { final port = r.bind(await lookup('nope')); return 'never: $port'; }); print(bad);}await sequences time, r.bind sequences failure, and the two are unaware of each other. That is the whole payoff.
FxDart ships a scope per failure representation, because — Chapter 10 again — there is no way to write one that covers them all:
import 'package:fxdart/fxdart.dart'; int? parseTeen(String s) { final n = int.tryParse(s); return (n != null && n >= 13 && n <= 19) ? n : null;} void main() { // Failure as a typed value. print(either<String, int>((r) { final n = r.ensureNotNull( parseTeen('15'), () => 'not a teen'); return n * 2; })); // Failure as null — no error value to carry. print(nullable((r) { final n = r.bind(parseTeen('15')); return n * 2; })); print(nullable((r) { final n = r.bind(parseTeen('42')); return n * 2; })); // Failure as a thrown exception, handled at the boundary. print(catching<int>(() => int.parse('nope'), (e, _) => -1)); // …or converted straight into a Left. print(eitherCatching<String, int>( (r) => int.parse('nope'), (e, _) => 'not a number'));}Three scopes, one idea: run straight-line code, exit at the first failure, convert the exit into whatever the caller's type says.
There is exactly one way to misuse a scope, and it follows from the mechanism: r may only be used while its scope is running. Capture it in a closure that outlives the block, and its escape has nowhere to land.
import 'package:fxdart/fxdart.dart'; void main() { late Raise<String> escaped; final result = either<String, int>((r) { escaped = r; // capturing the scope object… return 1; }); print(result); try { escaped.raise('too late'); // …and using it after it closed } catch (e) { print('caught: ${e.runtimeType}'); }}The library detects it and throws RaiseLeakedError rather than letting a stray control-flow jump escape into unrelated code. In practice the rule bites in one place: do not use r inside a callback that runs later — an unawaited future, a timer, a stream listener. Inside eitherAsync, stay on the awaited chain; that is the same rule stated for async.
🎓 This is an old idea with a new name. A delimited continuation captures "the rest of the computation up to a boundary" and lets you abandon or resume it;
shift/resetin Scheme,Contin Haskell, algebraic effect handlers in OCaml 5 and Koka are all this machinery.Raiseuses only the abandoning half, which is why it can be implemented with a private exception rather than a real capture of the stack. That restriction is also what makes it cheap and predictable: no re-entry, no resumption, no surprising re-execution — one exit, one boundary, one value.
Three or more fallible steps that share an error type, especially with early returns and guard conditions in between — r.ensure(cond, () => err) replaces the if (!cond) return Left(...) that a flatMap chain cannot express without another nesting level.
It is the wrong tool for independent validations (Chapter 6 — you want accumulation), for a single fallible call (return the Either directly), and anywhere the block hands r to code that will run later, which the leak rule forbids.
flatMap chain. Which version makes it easier to add a guard — "fail if the value drops below 3" — between steps?either return when the block throws a genuine exception rather than raising? Try it, and explain why that is the right default.r.recover(...) that continues the block after a failure? Answer in terms of the mechanism.nullable has no error value at all. What is its E type, and what does that tell you about the relationship between Either<E, A> and A??half(20).flatMap(half).flatMap(half).map((c) => c * 100). Adding a guard means inserting a flatMap((v) => v < 3 ? Left(...) : Right(v)) — a new nesting level and a new lambda — where the scope version adds one line: r.ensure(a >= 3, () => 'too small'). Guards are where the scope pulls ahead decisively.either unchanged. That is right because a thrown exception means "something happened that this error type does not describe" — silently converting it into a Left would launder a bug into a domain failure. eitherCatching exists for when you do want the conversion, and it is a separate function precisely so the choice is explicit. Chapter 18 develops this boundary.either sees the failure, the block's stack frames are already unwound and its local variables are gone. Resuming would require capturing the continuation before unwinding, which is the half of delimited continuations Raise deliberately does not implement. Recovery therefore happens outside, on the returned Either — result.fold(...) or getOrElse.E is effectively void/Null — there is nothing to carry. A? is Either<Unit, A> with the failure side carrying no information, so every nullable computation is an Either that has forgotten why it failed. That is the trade Chapter 18 examines: nullability is free and mute, typed errors cost a type parameter and can tell you what went wrong.In this chapter
- the two-track picture, and which operations move between tracks
- mapping the failure side, and why error types need it to compose
- recovery:
fold,getOrElse, and where a program stops being totalEitherinside a pipeline —sequence,separate, and the shape of a real import
Draw a success track and a failure track running side by side. Every fallible step is a switch: it either continues along the success track or diverts, once, onto the failure track — where it stays.
Figure 16-1. map runs only on the green track. flatMap is the switch. mapLeft is the only thing that touches the red track, and nothing rejoins without an explicit fold.
That picture is the whole semantics:
| Operation | Green track (Right) | Red track (Left) |
|---|---|---|
map(f) | applies f | passes through |
flatMap(f) | applies f, which may divert | passes through |
mapLeft(g) | passes through | applies g |
fold(l, r) | applies r | applies l |
import 'package:fxdart/fxdart.dart'; Either<String, int> parseQty(String s) { final n = int.tryParse(s); return n == null ? Either.left('not a number') : Either.right(n);} void main() { final ok = parseQty('12'); final bad = parseQty('twelve'); print([ok.map((n) => n * 2), bad.map((n) => n * 2)]); print(ok.mapLeft((e) => 'qty: $e')); print(bad.mapLeft((e) => 'qty: $e')); print(bad.fold((e) => 'failed — $e', (n) => 'got $n'));}Once on the red track a value is inert: every subsequent map and flatMap is a no-op. That is short-circuiting, and it needs no special support anywhere downstream — which is why you can add a step to the middle of a chain without touching the rest.
Two steps with different error types do not chain, and this is where real code usually stalls:
import 'package:fxdart/fxdart.dart'; class ParseError { const ParseError(this.input); final String input; @override String toString() => 'ParseError($input)';} class RangeError2 { const RangeError2(this.value); final int value; @override String toString() => 'RangeError2($value)';} // A common error type for the pipeline to speak.sealed class OrderError { const OrderError();} class BadInput extends OrderError { const BadInput(this.detail); final String detail; @override String toString() => 'BadInput($detail)';} Either<ParseError, int> parse(String s) { final n = int.tryParse(s); return n == null ? Either.left(ParseError(s)) : Either.right(n);} Either<RangeError2, int> inStock(int n) => n <= 5 ? Either.right(n) : Either.left(RangeError2(n)); void main() { // mapLeft lifts both into the pipeline's own error type. Either<OrderError, int> order(String raw) => either((r) { final n = r.bind( parse(raw).mapLeft((e) => BadInput('$e'))); final ok = r.bind( inStock(n).mapLeft((e) => BadInput('$e'))); return ok; }); print(order('3')); print(order('nine')); print(order('9'));}mapLeft is what makes a failure type local: each module can raise the error it knows about, and the caller translates at the boundary. Without it you end up with one god-enum of every error in the program, which is the typed-error equivalent of catching Exception.
A sealed error type (Chapter 3) pays off here: switch over OrderError at the top of the program is exhaustive, so adding a case is a compile error at every handler.
A railway is only useful if the tracks eventually merge back into something the caller can use. That merge is fold, and it is the point where you must decide what the failure means:
import 'package:fxdart/fxdart.dart'; Either<String, int> configPort(String? raw) => raw == null ? Either.left('missing') : (int.tryParse(raw) == null ? Either.left('not a number: $raw') : Either.right(int.parse(raw))); void main() { // Substitute a default — the failure was recoverable. print(configPort(null).fold((_) => 8080, (n) => n)); // Keep the reason and report it — the failure was not. print(configPort('x') .fold((e) => 'config error: $e', (n) => '$n')); // Switch on it, exhaustively, when the type is sealed. final result = configPort('9000'); final message = switch (result) { Left(:final value) => 'no port ($value)', Right(:final value) => 'port $value', }; print(message);}Three endings, one rule: the program becomes total again at the fold. Before it, a failure is data flowing along a track; after it, a decision has been made. Pushing that point as late as possible — to the HTTP handler, the UI, the CLI's exit code — is the single most useful habit this chapter has.
Real work has many rows, and Chapter 9's traversals are how the railway scales past one value:
import 'package:fxdart/fxdart.dart'; Either<String, int> parseRow(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad row: $s') : Either.right(n);} void main() { final rows = ['10', 'x', '30', 'y']; // All or nothing. print(fx(rows).map(parseRow).sequence()); // Everything that failed, and everything that did not. final (errors, values) = separateEither(rows.map(parseRow)); print('imported ${values.length}, rejected: $errors'); // Keep going, but report every reason at the end. print(fx(rows).map(parseRow).flattenOrAccumulate());}Three policies, one parser. That separation — the per-row function knows nothing about the policy, the pipeline chooses it — is what the two-track shape buys at scale.
🎓 Railway-oriented programming, and where the metaphor leaks. Scott Wlaschin's "railway-oriented programming" talk is where most people meet this picture, and it is a good one — but it describes
Eitherused monadically, which is only half the story. The picture has no way to draw two trains that both crashed (Chapter 6's accumulation), and it suggests failures are rare derailments when in most systems they are ordinary outcomes with their own logic. Keep the picture for sequencing and drop it when you need to combine independent results.
Domain failures the caller can act on: validation, parsing, authorisation, business rules, anything you would otherwise express as a nullable return plus a comment. It pays most where failures must carry information — which rule, which field, which id.
It does not pay for failures nobody can act on (out of memory, a bug), for a single call whose only failure mode is "not found" (A? is smaller), or for error types you cannot name yet — an Either<String, T> where the string is built by interpolation is a stringly-typed exception with extra steps.
map on a Left does nothing. Which functor law forces that, and what would break if a library "helpfully" ran the function anyway?getOrElse for Either in terms of fold. Then write orElse, which takes a fallback Either rather than a fallback value.Either<A, T> from one module and Either<B, T> from another, and the caller wants Either<C, T>. Sketch the three mapLeft calls and say where in a layered application they belong.separateEither returns (errors, values). Why that order, and what consequence does the choice have for reading code at a glance?left.map(id) must equal left. If map ran f on the failure value it would have to put the result somewhere — changing the Left's type or its content — so mapping identity would no longer be a no-op. What breaks concretely is composition: map(f).map(g) would apply both functions to an error that neither was written for, usually crashing inside code that assumed a success value.T getOrElse<T>(Either<Object?, T> e, T fallback) => e.fold((_) => fallback, (v) => v); and Either<E, T> orElse<E, T>(Either<E, T> e, Either<E, T> other) => e.fold((_) => other, (_) => e);. The second one is the semigroup on Either that keeps the first success — a monoid if you have an identity failure, which you usually do not.moduleA().mapLeft(toC) and moduleB().mapLeft(toC), both at the seam where the two modules meet — typically the use-case or service layer, not inside either module and not at the HTTP boundary. Translating too early couples the module to the caller's vocabulary; too late means the god-enum.(Left, Right) — the same order as the type parameters and as the switch arms, so nothing in the codebase ever asks "which one is first". Consistency here is worth more than any argument about which is more important: a reader who has to check the order once will have to check it every time.In this chapter
- the product question that decides fail-fast versus fail-slow
- the four tools, and which one fits which shape of validation
- independent and dependent rules, and why mixing them wrongly is the classic bug
- a complete form validation, from raw strings to a domain type
Chapter 6 established that only the applicative shape can accumulate. This chapter is about when it should, and the test has nothing to do with types:
Will a human read these errors and fix them in one pass?
If yes — a form, a config file, an import, an API request body — collect them all. Telling someone their postcode is wrong, waiting for a round trip, and then telling them their phone number is wrong is a bad product, not a bad program.
If no — a chain of internal steps, an authorisation check, anything where the second failure is a consequence of the first — fail fast. Ten cascading errors from one root cause is noise, and it hides the one that mattered.
import 'package:fxdart/fxdart.dart'; Either<String, int> parseAge(String s) { final n = int.tryParse(s); return n == null ? Either.left('age: not a number') : Either.right(n);} void main() { final raw = ['31', 'x', '44', 'y']; // 1. zipOrAccumulate2..5 — a fixed set of independent branches. print(either<Nel<String>, String>((r) => r.zipOrAccumulate2( (br) { if (raw[1] != '0') br.raise('second must be 0'); return raw[1]; }, (br) { if (raw[3] != '0') br.raise('fourth must be 0'); return raw[3]; }, (a, b) => '$a/$b', ))); // 2. mapOrAccumulate — the same rule over many items. print(mapOrAccumulate( (r, String s) => r.bind(parseAge(s)), raw)); // 3. flattenOrAccumulate — you already have the Eithers. print(fx(raw).map(parseAge).flattenOrAccumulate()); // 4. accumulate — the general scope, any number of branches, // and the only one that supports dependent rules. print(either<Nel<String>, int>((r) => r.accumulate((acc) { final first = acc.accumulating( (br) => br.bind(parseAge(raw[0]))); final third = acc.accumulating( (br) => br.bind(parseAge(raw[2]))); return first.value + third.value; })));}Choosing between them is mechanical:
| Shape | Tool |
|---|---|
| 2–5 named, independent fields | zipOrAccumulate2..5 |
| One rule, many items | mapOrAccumulate |
Already have Eithers | flattenOrAccumulate / .flattenOrAccumulate() |
| More than five branches, or dependent rules | accumulate |
The rule that makes accumulation correct is Chapter 6's distinction, and it has a precise API shape:
acc.accumulating(...) — an independent branch. It always runs; its failures are recorded rather than propagated.acc.dependent(...) — a dependent rule. It runs only when nothing has failed yet, because it reads another branch's value.Reading an Accumulated.value from a branch that failed detonates on purpose: it raises the whole accumulated list at once. That is what makes the final return safe — by the time you combine values, either all branches succeeded or you never get there.
Figure 17-1. Independent branches all run and drop their failures into one bucket. Dependent rules are downstream of that bucket: they run only if it is empty, because they read values that might not exist.
import 'package:fxdart/fxdart.dart'; class Signup { const Signup(this.email, this.age, this.plan); final String email; final int age; final String plan; @override String toString() => 'Signup($email, $age, $plan)';} Either<Nel<String>, Signup> validate(Map<String, String> form) => either((r) => r.accumulate((acc) { final email = acc.accumulating((br) { final v = form['email'] ?? ''; if (!v.contains('@')) { br.raise('email: must contain @'); } return v; }); final age = acc.accumulating((br) { final n = int.tryParse(form['age'] ?? ''); if (n == null) br.raise('age: not a number'); if (n != null && n < 18) br.raise('age: must be 18+'); return n ?? 0; }); final plan = acc.accumulating((br) { final v = form['plan'] ?? ''; if (v != 'free' && v != 'pro') { br.raise('plan: unknown "$v"'); } return v; }); // Dependent: only meaningful once age and plan parsed. acc.dependent((br) { if (plan.value == 'pro' && age.value < 21) { br.raise('plan: pro requires 21+'); } return null; }); return Signup(email.value, age.value, plan.value); })); void main() { print(validate( {'email': 'a@b.co', 'age': '30', 'plan': 'pro'})); print(validate( {'email': 'nope', 'age': 'x', 'plan': 'gold'})); print(validate( {'email': 'a@b.co', 'age': '19', 'plan': 'pro'}));}Three shapes of answer from one function: a value, every independent problem at once, and a dependent rule that only speaks when the values it needs exist.
Note the second case reports three errors from three fields, and the third reports one — the dependent rule — because the independent branches all passed. That is the behaviour a user expects and the reason this machinery exists.
age above raises up to two..value inside an independent branch. That is what dependent is for, and reading early detonates the whole scope.dependent or in a fail-fast scope, not in a branch.🎓 Why there is no
Validatedtype. Arrow 1.x had one — a separateValidated<E, A>whose applicative accumulated and which you converted to and fromEitherat every boundary. Arrow 2.x deleted it, and FxDart never had it: the same effect is available as a scope overEither<Nel<E>, A>, which means one result type in your domain signatures instead of two, and notoEither()calls scattered through the code. The theory lost nothing —Validatedwas only everEitherwith a different applicative instance, and since Dart cannot select instances by type anyway (Chapter 10), naming the behaviour at the call site is strictly more honest.
User-facing input of any kind; batch imports where a partial report saves another run; configuration, where every missing key should be reported before the process exits; API payloads, where a 400 that lists all violations is worth five that list one each.
It costs where failures are cheap to re-discover (a fast local retry), where the errors are for machines rather than humans (one code is enough), and where running every branch is expensive — accumulation means no short-circuiting, so five slow independent checks all run even when the first has already failed.
dependent to accumulating and predict the output for {'age': 'x', 'plan': 'pro'}.mapOrAccumulate over 10,000 rows collects every failure. What is the memory shape of that, and what would you do differently for a 10M-row import?Nel<String> and not List<String>? Give the state that List admits and Nel forbids.accumulating or dependent, and what changes if two branches call the same API?age.value from a failed branch, and detonate — raising the accumulated errors from inside the branch rather than at the end of the scope. The output is still a Left with the parse error, but the mechanism is an early exit rather than a clean accumulation, and a rule that needed to run after it would be skipped. dependent exists to make this impossible by construction.List<String> admits Left([]) — "this failed, and there are no reasons" — which is unrepresentable nonsense of exactly the kind Chapter 3 is about. Nel makes the guarantee structural: a failure always carries at least one reason, so no consumer needs a "no errors?" branch.accumulating, if the call is independent — you want its failure reported alongside the others. If two branches call the same API, they run sequentially inside the scope and you pay twice; hoist the call above the scope, pass the result in, and keep the branches pure. That also makes the validation testable without a network, which is Chapter 2's argument arriving again from a different direction.In this chapter
- Dart's three failure channels, and what each one can and cannot say
- the rule for choosing: can the caller do something about it?
- why
Eithernever makes a program exception-free, and why that is fine- converting at the edges, in both directions
Dart lets a function fail in three ways, and they are not interchangeable.
| Channel | Type says | Caller must | Carries |
|---|---|---|---|
throw | nothing | nothing (unchecked) | any object + stack trace |
A? | it might be absent | handle null | no reason |
Either<E, A> | it might fail, with E | handle both sides | a typed reason |
Each is right for something:
import 'package:fxdart/fxdart.dart'; // 1. Nullable: absence is the whole story.int? findIndexOf(List<String> xs, String needle) { final i = xs.indexOf(needle); return i == -1 ? null : i;} // 2. Either: the caller needs to know *why*.Either<String, int> parsePort(String s) { final n = int.tryParse(s); if (n == null) return Either.left('not a number: $s'); if (n < 1024) return Either.left('privileged: $n'); return Either.right(n);} // 3. Throw: the caller cannot act, and the program is broken.int divide(int a, int b) { if (b == 0) throw ArgumentError('b must not be zero'); return a ~/ b;} void main() { print(findIndexOf(['a', 'b'], 'z')); print(parsePort('80')); try { divide(1, 0); } catch (e) { print('threw: $e'); }}The choice rule is one question: can the caller do something specific about this failure? If yes, it belongs in the type — Either if the reason matters, A? if it does not. If no, throw: a bug, a broken invariant, or an environment failure nobody can recover from at that call site.
Figure 18-1. The question is not how bad the failure is, it is whether the caller has a reaction. Everything the caller can act on belongs in the return type; everything else belongs in the exception channel, where it will not clutter every signature between here and the top.
Here is the uncomfortable part, and the reason this chapter exists.
Either<E, A> in the signature does not mean "this function only fails with E". Dart has unchecked exceptions, so any code — yours, the SDK's, a dependency's — may throw at any time. An Either-returning function can still blow up with StateError, RangeError, OutOfMemoryError, or a bug in a transitive package.
So the honest statement is narrower, and it is still worth a lot:
Either<E, A>says: the failures this function models areE, and they are in the type. It says nothing about failures nobody modelled.
Compare with a checked-exception language, which promises the full set and pays for it with throws clauses on everything. Dart chose unchecked; a library cannot un-choose it. What FxDart adds is a channel for the failures you did think about, which is where the bugs actually come from.
import 'package:fxdart/fxdart.dart'; Either<String, int> risky(String s) => either((r) { if (s.isEmpty) r.raise('empty'); // Not modelled, and not caught by the signature: return int.parse(s); // throws on 'abc' }); void main() { print(risky('')); try { print(risky('abc')); } catch (e) { print('escaped the Either: ${e.runtimeType}'); } // If you want throws folded into the failure channel, say so. print(eitherCatching<String, int>( (r) => int.parse('abc'), (e, _) => 'not a number'));}eitherCatching is the explicit conversion, and it being explicit is the design: silently swallowing every throw would turn genuine bugs into domain failures, and you would find out in production, one Left('Bad state: no element') at a time.
A program has a boundary where the outside world's failure style meets yours. Both directions are one line, and both belong at that boundary, not scattered:
import 'package:fxdart/fxdart.dart'; class Config { const Config(this.port); final int port; @override String toString() => 'Config($port)';} // Inbound: a throwing API becomes a typed failure.Either<String, Config> loadConfig(Map<String, String> env) => eitherCatching( (r) { final raw = env['PORT']; r.ensureNotNull(raw, () => 'PORT is not set'); return Config(int.parse(raw!)); }, (e, _) => 'PORT is not a number', ); // Outbound: a typed failure becomes the framework's exception.Config loadOrThrow(Map<String, String> env) => loadConfig(env).fold( (e) => throw StateError('bad config: $e'), (c) => c, ); void main() { print(loadConfig({'PORT': '8080'})); print(loadConfig({})); print(loadConfig({'PORT': 'abc'})); try { loadOrThrow({}); } catch (e) { print('at the edge: $e'); }}Inbound conversion happens where you call code you do not control. Outbound conversion happens where a framework demands a throw — a Flutter build method, a test helper, main. In between, failures are values.
🎓 Errors versus exceptions, and what Dart's own SDK means. Dart's convention is that
Error(ArgumentError,StateError,RangeError) signals a programming mistake — the caller violated a contract and should be fixed, not handled — whileExceptionsignals a condition a correct program may still hit (FormatException,IOException). That maps neatly onto this chapter:Errorshould never be caught and converted into aLeft, because doing so hides a bug;Exceptionis a fine candidate foreitherCatching. When you write a library, following the convention is what lets your callers make this distinction at all.
A? is the cheapest failure channel Dart has, and it is genuinely the right answer more often than typed-error enthusiasts admit — with one test: is "absent" the entire message? A map lookup, a first-match search, an optional field: yes. A parse, a validation, an authorisation: no, because the caller will want to say what went wrong.
FxDart's nullable scope exists so the null-shaped chain gets the same straight-line treatment:
import 'package:fxdart/fxdart.dart'; class User { const User(this.name, this.managerId); final String name; final String? managerId;} final users = <String, User>{ 'u1': User('Ada', 'u2'), 'u2': User('Grace', null),}; String? managerName(String id) => nullable((r) { final user = r.bind(users[id]); final managerId = r.bind(user.managerId); final manager = r.bind(users[managerId]); return manager.name; }); void main() { print(managerName('u1')); print(managerName('u2')); // no manager print(managerName('u9')); // no such user}Three ways to be absent, one null out, and no pyramid of ?. and ??. Note what is missing from the output: which of the three it was. That is the exact information Either costs a type parameter to keep.
The rules in this chapter pay every time a new function is written, which makes them the highest-frequency decision in the book. Getting them right keeps signatures honest and keeps try blocks rare and meaningful.
They stop paying if applied dogmatically: an Either<String, T> on every private helper adds noise without adding information, and a codebase where main is the only try is a codebase that will drop a stack trace someone needed. Convert at boundaries, model what callers can act on, and let genuine bugs crash loudly.
A? / Either: JSON that fails to parse; a missing optional query parameter; a negative array length passed to your own function; a payment declined by the provider.int.parse throws and int.tryParse returns null. Which channel would Either have given, and what would it have had to invent?eitherCatching a separate function rather than the default behaviour of either? Describe the bug that would follow from the other choice.Either<E, A> but also throws on some inputs. How would you discover that, and what would you change — the code or the signature?Either if a user can fix the input, A? if the caller only branches on validity. Missing optional parameter: A? — absence is the message. Negative length: throw ArgumentError — the caller violated a contract, and the fix is in their code. Declined payment: Either with a typed reason, since the caller must show it to a human and possibly retry.Either<FormatException, int> — and had to invent an error type. That is the whole cost of the third channel: someone must decide what the failure is, name it, and maintain it. tryParse sidesteps that by saying only "no", which is why it is the more common call.Left would launder bugs into domain failures. A StateError from a library bug would arrive as a validation error, the caller would render it next to the postcode field, and nobody would ever see the stack trace. Explicitness means the conversion is a decision with a name attached.parse, !, first, [] on a list). Change the code: wrap the throwing call in eitherCatching and model the failure, or let it propagate deliberately if it is a bug. The one thing not to do is document it in a comment and leave the signature lying.In this chapter
- refactoring as a chain of substitutions, each justified by a named law
- a worked transformation: five stages down to two, on paper
- laws as property tests, with a generator and no test framework
- the preconditions that make the whole method valid, and how they fail
Chapter 2 defined referential transparency: a call can be replaced by its result. Its bigger sibling is equational reasoning — replacing any expression with an equal one, anywhere, and knowing the program is unchanged.
Every law in Part II is such an equation:
| Law | Equation |
|---|---|
| Functor composition | m.map(f).map(g) = m.map(g ∘ f) |
| Functor identity | m.map(id) = m |
| Monad left identity | of(a).flatMap(f) = f(a) |
| Monad associativity | m.flatMap(f).flatMap(g) = m.flatMap((x) => f(x).flatMap(g)) |
| Monoid associativity | (a + b) + c = a + (b + c) |
Read left to right they are optimisations; right to left they are clarifications. Both directions are legal, which is what makes them a tool rather than a fact.
Start with code nobody would defend:
import 'package:fxdart/fxdart.dart'; void main() { final source = [3, 8, 2, 9, 4]; // Before: five stages, two of them pointless. final before = fx(source) .map((n) => n) .map((n) => n * 2) .map((n) => n + 1) .filter((n) => n > 5) .fold(0, (a, b) => a + b); // After: two stages. Same value, by four substitutions. final after = fx(source) .map((n) => n * 2 + 1) .filter((n) => n > 5) .fold(0, (a, b) => a + b); print([before, after, before == after]);}The four steps, each with its licence:
map((n) => n) is map(id) → delete it. Functor identity.map(f).map(g) → map(g ∘ f), giving map((n) => n * 2 + 1). Functor composition.filter, because the predicate reads the mapped value — that reordering would need a precondition we do not have.fold is untouched; + on int is associative with identity 0, so the seed is genuinely the monoid's empty. Monoid laws.Two things are worth noticing. First, the transformation is mechanical — no cleverness, no testing required to believe it. Second, step 3 is where a careless "simplification" would introduce a bug, and the law is what tells you to stop.
Figure 19-1. Each arrow is a rewrite with a name. If you cannot name the law, you are not refactoring — you are rewriting and hoping.
A law is a property, and a property is a test you can run on many inputs. No framework is required to make the point:
import 'package:fxdart/fxdart.dart'; // A tiny generator: deterministic, so a failure is reproducible.List<int> sample(int n, int seed) { final rnd = createSeededRandom(seed); return List.generate(n, (_) => (rnd() * 200).floor() - 100);} Either<String, int> half(int n) => n.isEven ? Either.right(n ~/ 2) : Either.left('odd: $n'); Either<String, int> dec(int n) => Either.right(n - 1); void main() { var checked = 0; var failures = 0; for (final x in sample(200, 42)) { final m = Either<String, int>.right(x); // functor identity if (m.map((v) => v) != m) failures++; // monad left identity if (Either<String, int>.right(x).flatMap(half) != half(x)) { failures++; } // monad right identity if (m.flatMap((v) => Either<String, int>.right(v)) != m) { failures++; } // associativity final lhs = m.flatMap(half).flatMap(dec); final rhs = m.flatMap((v) => half(v).flatMap(dec)); if (lhs != rhs) failures++; checked += 4; } print('$checked properties checked, $failures failures');}Two hundred inputs, four laws, one line of output. In a real suite this becomes a package:test file (or package:glados for shrinking), but the shape does not change: generate inputs, assert an equation, run on every commit.
The reason to bother is not that FxDart's Either might be wrong. It is that your types have laws too — the Money that must never go negative, the Cache whose get after put must return what you put — and those are exactly as testable, with far more bugs to find.
// A property test for a type of your own.class Money { const Money(this.cents); final int cents; Money operator +(Money other) => Money(cents + other.cents); static const zero = Money(0); @override bool operator ==(Object o) => o is Money && o.cents == cents; @override int get hashCode => cents;} void main() { final values = [0, 1, 99, 100, -50, 123456].map(Money.new).toList(); var bad = 0; for (final a in values) { // identity if (a + Money.zero != a) bad++; if (Money.zero + a != a) bad++; for (final b in values) { for (final c in values) { // associativity if ((a + b) + c != a + (b + c)) bad++; } } } print('monoid violations: $bad');}Equational reasoning works when equals really are equal, and there are exactly three ways for that to fail:
Either, observational for Future, set-equality for Set. A law can hold under one and fail under another — Chapter 1's exercise made this concrete.Counted in Chapter 5 and Logged in Chapter 1 both had a lawful-looking map/flatMap and broke a law. Reading the name is not enough; the laws are a claim someone has to have checked.That third one is why the test in this chapter is not academic. A law you have not tested is a comment.
🎓 How far this goes. In a total, pure language the method scales all the way to proof: Haskell's
foldr/buildfusion, Coq's extraction, and GHC's rewrite rules are all equational reasoning performed by a machine on your behalf. Dart is neither total nor pure, so the method stays a human tool plus tests. That is a real difference in strength, not in kind: the same equations, checked by sampling rather than by proof, which is the same relationship property tests have with proofs everywhere.
Every time you simplify a pipeline, extract a helper, or fuse two stages for performance — that is this chapter's method, whether or not you name the law. Naming it is what turns "I think this is the same" into "this is the same, and here is why".
It pays hardest in review: "which law lets you move that filter before the map?" is a question that either has an answer or has found a bug.
It does not pay as ceremony in code that has no laws to appeal to — imperative setup, IO sequencing, UI callbacks. There, reasoning is about state and order, and equations have nothing to say.
fx(xs).filter(p).map(f) equal to fx(xs).map(f).filter(p)? State the precondition precisely, then give a p and f that break it.xs.map(f).toList().map(g).toList() → xs.map((x) => g(f(x))) .toList() step by step. Which step also changes the cost?map and flatMap agree: m.map(f) == m.flatMap((x) => Either.right(f(x))). Which law makes this true for every lawful monad?Cache has put then get returning the value put. Write that as an equation, then say what the equation implies about put's return type.p is a predicate on the unmapped value — that is, when the version after the swap tests the same thing. Break it with f = (n) => n * 2 and p = (n) => n > 5: filtering first keeps 6, 7, 8…, mapping first keeps 3, 4… doubled. The two answers differ because p was written for a different type of value.toList() (a materialisation, not a semantic step); apply functor composition to fuse the two maps; keep the final toList(). The cost changes at the first step: one intermediate list disappears, which is the allocation mechanism from Chapter 14 showing up in a refactor.map in terms of flatMap plus left identity: flatMap((x) => of(f(x))) applied to a Right(a) gives of(f(a)), which is Right(f(a)), which is map(f). Every lawful monad satisfies it, which is why "every monad is a functor" is a theorem rather than a convention.cache.put(k, v).get(k) == v — and notice the equation only type-checks if put returns the cache. A void put makes the property unstateable without talking about mutation and order, which is the same reason immutable APIs are easier to test: equations need values on both sides.In this chapter
- what a category is, in four lines, with Dart as the example
- functors and natural transformations, and which Dart code is which
- the monad definition in its original form, and how it maps to
flatMap- the famous sentence, decoded — and why you did not need it
This chapter is skippable. Everything it names, you have already used; nothing in it will change how you write Dart. Read it if you want the map that connects the parts, or to be able to read a paper without bouncing off the notation.
A category is:
f : A → B and g : B → C, an arrow g ∘ f : A → C;id_A : A → A for every object.Subject to two laws: composition is associative, and identity is neutral.
That is the whole definition, and Dart is an example of it. Objects are types; morphisms are functions; composition is what Chapter 4 wrote as compose2; identities are (x) => x. The two category laws are the two facts Chapter 4 relied on without ceremony.
Figure 20-1. A category is arrows that compose. Nothing about "elements" appears in the definition — which is exactly why the same theory covers types, and also sets, spaces, and orderings.
The step that trips people is that a category forgets what the objects are made of. int is not a set of numbers here; it is a dot with arrows leaving it. Every theorem in the subject is therefore a statement about the shape of composition, and that is why it transfers to programming at all.
A functor F between categories maps objects to objects and arrows to arrows, preserving identity and composition:
F(id_A) = id_F(A)F(g ∘ f) = F(g) ∘ F(f)
Those are precisely Chapter 5's two laws. In programming we use endofunctors: F maps the category of Dart types to itself. List sends the object int to the object List<int>, and sends the arrow int → String to the arrow List<int> → List<String> — the latter is map.
So map is the arrow-half of a functor, and the reason it must not change the structure is that a functor is defined as the thing that preserves it.
Given two functors F and G, a natural transformation α : F ⇒ G is a family of arrows α_A : F(A) → G(A), one per type, satisfying:
α_B ∘ F(f) = G(f) ∘ α_A
In Dart: a generic function that changes the container without touching the contents, and commutes with map. You have written several:
import 'package:fxdart/fxdart.dart'; // A natural transformation: Either<E, _> ⇒ Option-ish (_?)A? toNullable<E, A>(Either<E, A> e) => e.fold((_) => null, (a) => a); void main() { int f(int n) => n * 3; final r = Either<String, int>.right(7); final l = Either<String, int>.left('nope'); // naturality: map then transform == transform then map print([toNullable(r.map(f)), toNullable(r)?.let(f)]); print([toNullable(l.map(f)), toNullable(l)?.let(f)]);} extension Let<T> on T { R let<R>(R Function(T) f) => f(this);}Both sides agree, for both cases — that is naturality, and it is the formal statement of "this conversion does not look at the values". toList(), toAsync(), first, sequence and flatten are all natural transformations, which is why none of them can be surprising: they cannot depend on the contents they are moving.
A monad on a category C is a triple (T, η, μ):
T — an endofunctor;η : Id ⇒ T — a natural transformation, "unit";μ : T² ⇒ T — a natural transformation, "multiplication" or "join";satisfying three coherence conditions:
μ ∘ T(μ) = μ ∘ μ_T (associativity)μ ∘ T(η) = id = μ ∘ η_T (unit, both sides)
Translated:
| Category theory | Dart |
|---|---|
T | the type constructor — Either<E, _>, List, Future |
η (unit) | of / Either.right / [x] / Future.value |
μ (join) | flatten — List<List<A>> → List<A> |
flatMap(f) | μ ∘ T(f) — map, then flatten |
| the coherence conditions | Chapter 1's three laws |
import 'package:fxdart/fxdart.dart'; void main() { // μ: T² ⇒ T. Dart spells it `flat` / `expand(id)`. final nested = [ [1, 2], [3], [4, 5] ]; print(fx(nested).flat().toList()); // flatMap = μ ∘ T(f): map to a nested structure, then join. int f(int n) => n; final viaMapThenJoin = fx([1, 2, 3]).map((n) => [n, n * 10]).flat().toList(); final viaFlatMap = fx([1, 2, 3]).flatMap((n) => [n, n * 10]).toList(); print([viaMapThenJoin, viaFlatMap, f(1)]);}The two definitions — flatMap versus map + join — are interchangeable, which is why some languages give you one and some the other, and why Chapter 1 could define the monad without mentioning μ at all.
A monad is a monoid in the category of endofunctors.
You now have every piece:
List and Future, arrows are natural transformations between them.F then G), which plays the role of multiplication.T with μ : T ∘ T ⇒ T (combine) and η : Id ⇒ T (unit), obeying associativity and identity — Chapter 8's two laws, one level up.Which is exactly the definition above. The sentence is true, precise, and useless as a first explanation — it defines the special case by pointing at the general one, which is the right order for mathematics and the wrong one for learning.
🎓 What the theory buys, honestly. Not code — you have written every construct in this book without it. What it buys is transfer: the same theorems apply to parsers, probability distributions, build systems and state machines, so a result proved once is available everywhere. And it buys vocabulary precise enough that two people can disagree productively. If you want to go further, the useful next objects are adjunctions (which explain why
flatMapandmapcome in pairs) and free monads (which explain interpreters); neither is needed for anything in Part I to IV.
Reading. Papers, Haskell libraries, Scala's Cats, and any discussion where someone says "that is just a natural transformation" — this chapter is the decoder ring for those.
Also naming. Once you can say "this conversion is natural", you have a precise way to state a design rule ("it must not inspect the contents") that no amount of prose in a doc comment achieves.
It does not earn its keep in code review, in commit messages, or in conversation with a colleague who has not read it. The vocabulary is a tool for thinking, and using it as a credential is how the subject got its reputation.
compose2?List.reversed a natural transformation from List to List? Check naturality with f = (n) => n * 2, then say what makes it natural despite changing order.first maps List<A> to A?. Is it natural? What about sortBy, which maps List<A> to List<A>?flatMap as μ ∘ T(f) for Either<E, _>. What is μ for Either, concretely?compose2(compose2(f, g), h) and compose2(f, compose2(g, h)) both call h(g(f(x))). Identity: compose2(id, f) and compose2(f, id) both call f(x). You are relying on compose2 being just application — no logging, no memoisation, nothing extra. An impure compose2 would break the category laws, which is the same observation Chapter 2 made about substitution.xs.map(f).reversed and xs.reversed.map(f) give the same list, because reversed rearranges positions without consulting values. Natural does not mean "structure-preserving" in the sense of order — it means "independent of the contents", and a permutation qualifies.first is natural: xs.map(f).first equals f(xs.first) when non-empty, and both are absent when empty. sortBy is not — it inspects the values to decide the order, so xs.map(f).sortBy(k) and xs.sortBy(k).map(f) differ in general. That is the clearest one-line test for naturality: does it look at the contents?μ : Either<E, Either<E, A>> → Either<E, A> collapses the two layers — Left(e) stays Left(e), Right(Left(e)) becomes Left(e), Right(Right(a)) becomes Right(a). Then flatMap(f) is map(f) followed by that collapse, which is exactly what the implementation does when it pattern-matches on both levels.In this chapter
- where each idea in this book was invented, and what problem it solved there
- the four translations, and the specific thing each one lost
- why FxDart's API looks the way it does — FxTS's names, Arrow's errors
- what to take from each ancestor when you read their documentation
| Introduced / popularised | Lost in translation | |
|---|---|---|
| Haskell (1990) | typeclasses, monads as an interface, do | nothing — it is the source; the cost is the language itself |
| Scala (2004) | monads in an OO language, for-comprehensions, Cats | implicit resolution complexity; two syntaxes for everything |
| Kotlin + Arrow (2017) | typed errors without HKTs, Raise, context receivers | generic abstraction over effects — deleted in Arrow 2 |
| FxTS (2021) | lazy pipelines, concurrent(n), in TypeScript | laws as a stated contract; TS types are erased at runtime |
| FxDart (2025) | the FxTS model + Arrow's errors, in Dart | curried pipe, and every HKT abstraction |
Read the right-hand column as a single sentence: each translation kept the shape and dropped whatever its host language could not carry.
Figure 21-1. The ideas travel; the mechanisms do not. Every arrow is a port that preserved the vocabulary and re-implemented the machinery with whatever the new language had.
Monads were introduced to Haskell to solve a specific problem — how a pure language can do IO — and the answer was to make effects into values with a common interface. That interface is a typeclass, which is why every chapter of Part II is shaped like one: a type constructor, a couple of operations, and laws that instances must satisfy.
What Haskell contributed that survives everywhere: the laws are the contract. A Monad instance that breaks associativity is a bug, not a variant. Every library in this lineage inherits that standard even when it cannot enforce it.
What does not translate: laziness by default, purity enforced by the compiler, and typeclass resolution. Reading Haskell for ideas is worthwhile; copying its signatures into Dart is not.
Scala showed that the vocabulary works in a language with subtyping and methods — flatMap as a method rather than a free function, for as desugaring, and (in Cats) the whole typeclass tower re-created with implicits and higher-kinded types.
It also demonstrated the failure mode that made later designers cautious: EitherT[Future, E, A] and friends. Monad transformers are the general answer to "monads do not compose", and in practice they produce code with a lift at every level and error messages that are impossible for a newcomer. Chapter 7's depth box records why Arrow and FxDart both refused this route.
Arrow 1.x tried to be Cats for Kotlin, including the Kind encoding Chapter 10 demonstrated. Arrow 2.x deleted almost all of it and rebuilt around one idea: a scope with a non-local exit.
either { val x = parse(raw).bind(); ... } // Kotlin, Arrow 2either((r) { final x = r.bind(parse(raw)); ... }) // Dart, FxDart
That is the direct ancestor of Chapter 15, and the reason FxDart's typed-error API uses Arrow's vocabulary — Raise, bind, ensure, accumulate, NonEmptyList, zipOrAccumulate — rather than inventing new names. When Arrow's documentation explains a subtlety about accumulation, it applies here too.
Kotlin has one thing Dart does not: context receivers, which let bind() be an extension available implicitly inside the scope. Dart needs the explicit r. prefix. That is a real ergonomic loss and the reason FxDart's scopes are "scope-first by design": you type r. and the editor lists the vocabulary.
Everything in Parts I and III comes from the other parent. FxTS brought:
concurrent(n) and its back-channel — Chapter 13's mechanism, invented there;What could not be ported is Chapter 4's subject: FxTS's curried pipe needs variadic generics, which TypeScript fakes with overloads and Dart cannot fake at all. FxDart's typed chain is the replacement, and WHY_CURRIED.md is the written record of that decision — worth reading as an example of documenting a port's deviations rather than pretending they do not exist.
import 'package:fxdart/fxdart.dart'; Either<String, int> parse(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad: $s') : Either.right(n);} void main() async { // FxTS ancestry: lazy chain, bounded concurrency. final ports = await fx(['8080', '9000', '7000']) .toAsync() .mapConcurrent(2, (s) async => s) .toList(); // Arrow ancestry: typed failures, scope, accumulation. final parsed = fx(ports).map(parse).flattenOrAccumulate(); print(parsed); print(fx(['1', 'x']).map(parse).flattenOrAccumulate());}Two ancestries, one library, and the seam between them is deliberate: the pipeline half never mentions Either, and the error half never mentions laziness. They meet only at the traversals of Chapter 9.
🎓 Ideas older than all of them. Monads entered computing through Eugenio Moggi's 1989 work on categorical semantics of programs, and Philip Wadler's papers turned them into a programming technique.
NonEmptyList, applicative validation and the "railway" picture come from the same period's ML and Haskell practice. Delimited continuations — Chapter 15's mechanism — are older still, from 1980s Scheme. Almost nothing in this book was invented in the last decade; what changed is that mainstream languages grew enough type system to host the ideas, which is why the same set arrived in Kotlin, TypeScript, Swift and Dart within a few years of each other.
When you are stuck. Nearly every question you can ask about this vocabulary has been answered at length in one of the four ancestor communities, and knowing which one to search is most of the work.
It also earns its keep as inoculation: seeing that each language paid a different price for the same ideas makes it obvious that the ideas are not the property of any one syntax — and that a Dart port refusing a Haskell feature is usually a design decision, not a shortfall.
Kind encoding and its Validated type. What did each deletion cost, and what did it buy?pipe takes a value and a list of curried operators; FxDart's chain is methods. Name one thing FxTS can express that FxDart cannot, and one thing FxDart gets that FxTS does not.Future + Either with EitherT; FxDart solves it with eitherAsync. Which one generalises to a third effect, and what does the other one do instead?Kind cost generic abstraction over effects — no single traverse, no shared combinators — and bought readable types and error messages, plus an API a newcomer can use without learning the encoding. Deleting Validated cost a dedicated accumulating type and bought one result type in every signature; the accumulation behaviour moved into a scope, which is strictly more explicit at the call site.map(f) as a value and pass it around — operators are first-class, so you can build a pipeline dynamically from a list of stages. FxDart gets full static types through the whole chain, including inference into the callbacks, which FxTS's pipe only achieves through a wall of hand-written overloads.EitherT generalises: it is one wrapper per monad, so a third effect is another transformer in the stack (at the cost of lifts everywhere). eitherAsync does not generalise — FxDart writes each useful combination by hand, and there are only a few, because the combinations people actually use are few.In this chapter
- five shapes where the imperative version is simply better
- the cost nobody puts in the README: reading, debugging, hiring
- a checklist you can apply before writing the pipeline
- what to keep even when you throw the rest away
Everything in this book is a tool with a price. Twenty-one chapters have argued for the tools; this one prices them, because a technique you cannot argue against is a belief, not an engineering choice.
1. Hot, uniform, fully consumed. Chapter 14's losing shape: many cheap stages, every element used, in a path that runs constantly. The per-element indirection is pure overhead and there is no refused work to earn it back.
void main() { final xs = List.generate(8, (i) => i); // Sometimes this is just the right code. var sum = 0; for (final x in xs) { if (x.isOdd) sum += x * x; } print(sum);}2. Index arithmetic. Sliding comparisons with irregular strides, in-place transforms, algorithms defined on positions (binary search, two pointers, dynamic programming tables). The pipeline vocabulary describes sequences of values; when your algorithm is about positions in a buffer, translating it costs clarity and buys nothing.
3. One step. A single map over a five-element list is list.map(f).toList(). Wrapping it in fx(...) adds a name to learn and a type to explain, for zero benefit. The same goes for one fallible call: int.tryParse returning null is complete; making it an Either<String, int> means inventing an error message nobody reads.
4. Genuinely imperative work. Building a buffer, driving a state machine, writing bytes to a socket, orchestrating a migration script. These are sequences of effects, and Chapter 2's advice — push effects to the edges — means the edges exist and should look like what they are.
5. Push-shaped problems. Chapter 12's rule. UI events, sockets, timers, and anything where several consumers must see the same event: use Stream (or the fxEvents layer) and stop trying to pull.
Reading cost is real and unevenly distributed. A ten-stage chain is denser than the loop it replaced. Dense is good when the reader knows the vocabulary and bad when they do not, and your team is the variable that decides which.
Debugging is worse. A stack trace inside a lazy pipeline shows iterator frames, not your stage names. A breakpoint in a callback fires interleaved with other stages (Chapter 5's fusion, working as designed). Print debugging needs peek. None of this is fatal; all of it is slower than stepping through a loop.
The abstraction can outgrow the problem. The failure mode is not one clever chain — it is a codebase where a reader must hold four typeclass names in their head to follow a three-line function. If naming the abstraction takes longer than the code it saves, it lost.
Team cost compounds. Every construct here is a thing to teach. That is fine for map/filter/fold, which any Dart developer knows; it is a real investment for accumulate, traverse and the Raise scope. Spend it where it pays and not everywhere.
Figure 22-1. Four questions, asked before writing the pipeline. Three "no"s and a loop is the right answer — which is a normal outcome, not a failure of nerve.
Before reaching for the pipeline vocabulary:
take, first, a selective filter, an early exit. If yes, laziness is earning its keep (Chapter 11).concurrent(n) is worth the chain on its own (Chapter 13).Three or four yeses: use the tools. One yes: use the tools for that part only. Zero: write the loop, and do not apologise.
Even if you never use FxDart again, four things from this book survive:
sealed and records, and it prevents more bugs than everything else here combined.A? for absence, a typed value for anything the caller acts on.Those four are style-independent. The rest is a toolkit, and toolkits are chosen per job.
🎓 The strongest version of the counter-argument. It is not "FP is slow" (Chapter 14 measured: usually a tie) and not "it is hard" (the vocabulary is a dozen words). It is locality: an imperative loop puts everything a reader needs in eight consecutive lines, while a pipeline distributes behaviour across callbacks, laws and library semantics the reader must already know. Abstraction trades local clarity for global structure. When a codebase has little global structure to gain — a script, a one-off, a small tool — the trade is simply bad, and no amount of elegance changes the arithmetic.
Every time you are about to write a pipeline because it feels sophisticated rather than because it is shorter or safer. The checklist takes ten seconds and is the cheapest code review you will ever run.
map over a small fixed list that could be a for — and rewriting them is a small, real improvement, not a defeat.for (final x in xs) if (x.isEven) sum += x; versus a chain plus a fold with a seed. Debugging at 2am favours the version where every value is visible in a local variable — which is a genuine argument, not a concession.take/first after a stage whose native equivalent did the whole job (a full sort, a full scan). If your hot path consumes everything it produces, that mechanism is unavailable to you and the ratio will not favour the pipeline.Either forces every caller to handle a case whose only sensible handling is to give up — and it hides the stack trace that would have located the bug.Every bolded term in the book, with the names it goes by elsewhere and the place it is spelled in Dart. The chapter number is where it is introduced.
| Term | Also called | In Dart / FxDart | Ch. |
|---|---|---|---|
| Functor | — | any type with a lawful map | 5 |
| Applicative | applicative functor | map2, zipOrAccumulate, Future.wait | 6 |
| Monad | — | any type with of + lawful flatMap | 1 |
| Monoid | — | a fold seed plus an associative combine | 8 |
| Semigroup | — | associative combine, no identity — Nel | 8 |
| Traversable | — | sequence, mapOrAccumulate, Future.wait | 9 |
| Kleisli composition | monadic composition, >=> | (a) => f(a).flatMap(g) | 7 |
| Natural transformation | — | a generic conversion that ignores contents | 20 |
| Higher-kinded type | HKT, type constructor polymorphism | not expressible in Dart | 10 |
| Term | Also called | In Dart / FxDart | Ch. |
|---|---|---|---|
| of | pure, return, unit, η | Either.right, [x], Future.value, fx([x]) | 1 |
| map | fmap, <$> | map, Future.then | 5 |
| flatMap | bind, >>=, chain | flatMap, expand, Future.then, r.bind | 1 |
| join | flatten, μ | flat(), expand(id) | 20 |
| map2 | zipWith, liftA2 | map2, zipOrAccumulate2 | 6 |
| traverse | — | mapOrAccumulate, .map(f).sequence() | 9 |
| sequence | — | sequenceEither, Future.wait | 9 |
| fold | catamorphism, reduce with seed | fold, Either.fold | 8 |
| Term | Also called | In Dart / FxDart | Ch. |
|---|---|---|---|
| Lazy | deferred, non-strict | any Fx stage — nothing runs until a terminal | 11 |
| Terminal operator | consumer, sink | toList, each, fold, first, sum | 11 |
| Pull | interactive, Iterable-shaped | Iterable, FxAsyncIterable | 12 |
| Push | reactive, observable | Stream, FxEvents | 12 |
| Backpressure | flow control | not asking for the next value | 12 |
| Fusion | stage fusion, deforestation | one pass through a whole chain | 5 |
| Concurrency | — | concurrent(n), mapConcurrent — overlapping waits | 13 |
| Parallelism | — | isolates — overlapping computation | 13 |
| Term | Also called | In Dart / FxDart | Ch. |
|---|---|---|---|
| Either | Result, Validation, disjoint union | Either<L, R>, Left, Right | 16 |
| Raise scope | context receiver scope, effect scope | either((r) { … }), r.bind, r.ensure | 15 |
| Delimited continuation | shift/reset, effect handler | the non-local exit inside either | 15 |
| Short-circuit | fail-fast | the first Left ends the chain | 16 |
| Accumulation | fail-slow, applicative validation | accumulate, zipOrAccumulate, mapOrAccumulate | 17 |
| NonEmptyList | Nel | NonEmptyList<E> — extension type over List | 8 |
| Monad transformer | EitherT, OptionT | not used — eitherAsync instead | 7 |
| Term | Also called | In Dart / FxDart | Ch. |
|---|---|---|---|
| Pure function | — | same inputs, same output, nothing observable | 2 |
| Referential transparency | substitutability | replacing a call with its result | 2 |
| Effect | side effect | anything observable besides the return value | 2 |
| Total function | — | defined for every input — fold is, reduce is not | 8 |
| Product type | record, tuple, struct | (A, B), class fields | 3 |
| Sum type | tagged union, variant, coproduct | sealed class + switch | 3 |
| Algebraic data type | ADT | sums and products together | 3 |
| Currying | — | .curried / .uncurried | 4 |
| Partial application | — | a closure capturing some arguments | 4 |
| Higher-order function | — | takes or returns a function | 4 |
| Equational reasoning | — | replacing equals with equals, by law | 19 |
| Law | property, contract | an equation instances must satisfy | 1, 5, 8 |
| Category | — | objects + composable arrows + identities | 20 |
A short decoder for cross-language reading:
flatMap = bind = >>= = chain = SelectMany (C#) = expand (Dart's Iterable).of = pure = return = unit = just = Right = Future.value.map = fmap = <$> = Select (C#) = then (Dart's Future, which is also its flatMap).Either<E, A> = Result<A, E> (Rust — note the flipped parameters) = Validation (when the applicative accumulates).NonEmptyList = Nel = NonEmptyChain (Cats).Each law, in one line of code, with the refactor it permits. Everything here is testable the way Chapter 19 tested it: generate inputs, assert the equation.
| Law | Equation |
|---|---|
| Identity | m.map((x) => x) == m |
| Composition | m.map(f).map(g) == m.map((x) => g(f(x))) |
Licenses: deleting a no-op map; fusing two maps into one pass; splitting one map into two for readability.
Breaks when: map does anything besides apply the function — counting, logging, caching, reordering (Counted in Chapter 5).
| Law | Equation |
|---|---|
| Left identity | of(a).flatMap(f) == f(a) |
| Right identity | m.flatMap(of) == m |
| Associativity | m.flatMap(f).flatMap(g) == m.flatMap((x) => f(x).flatMap(g)) |
Licenses: inlining a wrapped value; deleting a no-op step; regrouping a chain — which is what "extract this into a helper" does.
Breaks when: chaining itself has a cost the type records (Logged in Chapter 1).
Corollary: m.map(f) == m.flatMap((x) => of(f(x))) — every lawful monad is a lawful functor.
| Law | Equation |
|---|---|
| Identity | of(id).ap(m) == m |
| Homomorphism | of(f).ap(of(a)) == of(f(a)) |
| Interchange | u.ap(of(a)) == of((f) => f(a)).ap(u) |
| Composition | of(compose).ap(u).ap(v).ap(w) == u.ap(v.ap(w)) |
In map2 terms the useful consequence is: map2 must run both structures and combine them, never inspect one to decide about the other.
Licenses: running independent branches concurrently; accumulating their failures; reordering independent branches (results combine the same way).
Breaks when: the "independent" branches secretly depend on each other — shared mutable state in a validation branch is the usual culprit.
| Law | Equation |
|---|---|
| Associativity | (a + b) + c == a + (b + c) |
| Left identity | empty + a == a |
| Right identity | a + empty == a |
Licenses: chunking a fold; parallel or incremental reduction; using the identity as a fold seed so the empty case is total.
Breaks when: the operation is subtraction-shaped, or the "identity" is guessed from the type rather than the operation (0 for multiplication).
Not implied: commutativity — a + b == b + a is a separate, stronger law that most useful monoids lack.
| Law | Statement |
|---|---|
| Identity | traversing with the identity applicative is map |
| Composition | traversing with two applicatives in sequence == traversing once with their composition |
| Naturality | a natural transformation commutes with traverse |
Licenses: choosing where to traverse in a chain; swapping fail-fast for accumulating without touching the per-element function.
| Law | Equation |
|---|---|
| Naturality | α(m.map(f)) == α(m).map(f) |
Licenses: moving a conversion (toList, toAsync, toNullable, first) across a map, in either direction.
Breaks when: the conversion inspects the values — sortBy is the standard counterexample.
| Law | Equation |
|---|---|
| Associativity | (h ∘ g) ∘ f == h ∘ (g ∘ f) |
| Identity | id ∘ f == f == f ∘ id |
Licenses: extracting or inlining any composition of pure functions, including pipeline stages.
Either and Money; observational for Future; set-equality for Set. A law can hold under one and fail under another (Chapter 19).import 'package:fxdart/fxdart.dart'; // Generate → assert the equation → report. The seed is fixed so// a failure can be reproduced exactly.void main() { final rnd = createSeededRandom(7); final inputs = List.generate(100, (_) => (rnd() * 100).floor()); Either<String, int> f(int n) => n.isEven ? Either.right(n ~/ 2) : Either.left('odd'); Either<String, int> g(int n) => Either.right(n + 1); var violations = 0; for (final x in inputs) { final m = Either<String, int>.right(x); if (m.map((v) => v) != m) violations++; final lifted = Either<String, int>.right(x); if (lifted.flatMap(f) != f(x)) violations++; if (m.flatMap(f).flatMap(g) != m.flatMap((v) => f(v).flatMap(g))) { violations++; } } print('violations: $violations');}Ordered by how soon they are useful, with a plain note on difficulty. Nothing here is required; every chapter of this book stands on its own.
| Source | Good for | Difficulty |
|---|---|---|
| FxDart 101 tutorials | the API surface, one function at a time, with runnable demos | easy |
| Dart vs FxDart (52 examples) | whether a pipeline is the right tool for a given task, with verdicts | easy |
| RxDart vs FxDart | Chapter 12's pull/push decision, applied to 50 real problems | easy |
WHY_CURRIED.md | the reasoning behind Chapter 4 — what a port owes its source | moderate |
ARROW_MIGRATION_BLOCKER.md | the HKT wall of Chapter 10, documented as it was hit | moderate |
benchmark/AUTHORING.md | how the Chapter 14 numbers are produced, and how to add a case | moderate |
| Source | Good for | Difficulty |
|---|---|---|
| Arrow (Kotlin) — typed errors guide | Part IV's specification, effectively; the Raise scope, accumulation, and the design rationale | moderate |
| FxTS docs | the operator catalogue and concurrent(n); names map onto FxDart almost exactly | easy |
| Cats (Scala) — typeclass docs | the tower stated generically: functor → applicative → monad → traverse | hard without Scala |
Haskell Data.Functor / Control.Monad | the laws in their original form, tersely | hard |
| You want to know | Go to |
|---|---|
| "Which FxDart function does X?" | the 101 tutorials |
| "Should I use a pipeline here at all?" | Dart vs FxDart, and Chapter 22 |
| "How do I model this failure?" | Chapter 18, then Arrow's typed-errors guide |
"Why is there no generic traverse?" | Chapter 10, then ARROW_MIGRATION_BLOCKER.md |
| "Is my type lawful?" | Chapter 19 and Appendix B, then write the property test |
| "What is a monad really?" | Chapter 1, then Wadler, then Chapter 20 |
The order that works is: use it, name it, then formalise it. Every chapter in this book was written that way, and the sources above are best approached the same way — find the construct you have already been using, read the section that names it, and stop there until the next time you meet it.
Reading a theory book front to back without code in front of you is the approach that produces the reputation, and it does not work.