本页尚未翻译,因此以英文显示。

Functional Programming Theory

The ideas behind the pipeline — for working Dart developers

How to read this book

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.

Turning pages

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, B are ordinary types (int, User). M<A> is a value of type A sitting inside some structure MList<A>, Future<A>, Either<E, A>. A function written A → 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.

Part I · Shapes you already use
The structures hiding in code you write every day — and the vocabulary that names them.
1
What a monad actually is

In this chapter

Start from the code, not the definition

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 inchain 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.

The two operations, precisely

Write M<A> for a value of type A inside a structure M. A monad is a type constructor M plus:

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 define map(f) as flatMap((a) => of(f(a))). The reverse is not true, which is why the tower has more than one floor.

You have been writing flatMap in disguise

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 three laws

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.

  1. Left identity. of(a).flatMap(f) = f(a). Boxing a value and immediately chaining a step is the same as just calling the step.
  2. Right identity. m.flatMap(of) = m. Unwrapping a box and putting the value straight back changes nothing.
  3. Associativity. 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)));}

What a broken law costs

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 → C with two natural transformations, η : Id ⇒ T (that is of) and μ : T² ⇒ T (that is flatten, from which flatMap(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.

What FxDart actually implements

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.

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.

When the vocabulary earns its keep

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.

Exercises

  1. 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?
  2. Write 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.
  3. 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?
  4. Fix Logged so all three laws hold, then chain two steps in both groupings and show the counts agree.

Solutions

  1. Yes, with a caveat about equality. All three laws hold when equality is set equality, because 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.
  2. 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.
  3. They are not the same object, and == 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.
  4. Remove + 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.
2
Purity and effects

In this chapter

The substitution test

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.

What purity buys

Four capabilities, and you already rely on all of them:

CapabilityWhy purity is required
MemoiseCaching a result assumes the second call would have done the same thing
ReorderMoving a line assumes nothing else observes when it ran
ParalleliseRunning two calls at once assumes neither can see the other
TestAsserting 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.

Where effects hide in Dart

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:

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".

The seam

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);}

When this earns its keep

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.

Exercises

  1. Is List.of(items) pure? Consider both == and identical as the way a caller might observe the result.
  2. Write a function that is pure in Dart's eyes but depends on a mutable field that never changes after construction. Is it referentially transparent? What would break the moment someone made the field non-final?
  3. memoize on a function of type int Function(int) is safe. What goes wrong if the argument type is a mutable List<int>?
  4. Take the receipts pipeline above and add a requirement: log every order that was filtered out. Do it without making receipts impure.

Solutions

  1. Pure by ==, 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.
  2. Something like 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.
  3. 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.
  4. Return the rejected orders instead of logging them — 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.
3
Making illegal states unrepresentable

In this chapter

Counting the states

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:

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.

The sum type, and the feature that makes it pay

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.

Products: records, and where they stop

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 functions A → B have |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.

The refactor, in three moves

  1. Count. Write down how many states the type admits, and how many the domain has. If they differ, the gap is your bug budget.
  2. Name the real cases. One sealed subclass each, each carrying exactly the data that case needs — Loaded has data and no message, and no nullable anything.
  3. Delete the guards. Every 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.

When this earns its keep

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.

Exercises

  1. How many values does (bool, String?) have if String has n values? And Either<bool, bool>?
  2. Model a traffic light that is either red, amber, green, or "flashing amber with a reason". Which cases carry data, and how many states does your type admit?
  3. Take the 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?
  4. Either<String, int> and (String?, int?) can both represent "a failure or a number". Give a concrete reason to prefer the first.

Solutions

  1. (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.
  2. Three constant cases plus one that carries a 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.
  3. All sixteen minus the four real ones: 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.
  4. 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.
4
Functions as values

In this chapter

Composition

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.

Partial application vs currying

They get used interchangeably and they are not the same thing.

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.

Why FxTS's pipe could not be ported

FxTS 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) → C and A → (B → C) carry exactly the same information — you can convert either way without loss, which is what .curried / .uncurried demonstrate 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.

Higher-order functions you already use

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());}

When this earns its keep

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.

Exercises

  1. 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?
  2. Write compose3 for three one-argument functions using compose2 twice. Then argue that the two ways of grouping the calls give the same function.
  3. 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?
  4. Rewrite fx(xs).filter(small).filter(odd) as a single filter. Is that always a safe refactor? What property of filter does it rely on?

Solutions

  1. The chain applies left to right: 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.
  2. 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.
  3. 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.
  4. 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.
Part II · The tower
Functor, applicative, monad, monoid: what each one buys, and what it costs.
5
Functor

In this chapter

One operation

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.

The two laws

  1. Identity. m.map((x) => x) == m. Mapping the identity function changes nothing at all — not the values, not the shape, not anything observable.
  2. Composition. 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.

What the laws forbid

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.

The composition law is a performance feature

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: F maps the type A to the type F<A>, and map lifts an arrow A → B to an arrow F<A> → F<B>. Chapter 20 draws the diagram; nothing above depends on it.

Functors that are not containers

"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.

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.

When this earns its keep

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.

Exercises

  1. Prove — informally, by cases — that Either.map satisfies the identity law. How many cases are there, and why is that number the whole proof?
  2. 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.
  3. If a type has 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?
  4. FxDart's peek returns the same element type. Is peek a map? What law does it break, and which chapter's vocabulary explains why nobody minds?

Solutions

  1. Two cases. 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.
  2. It does. {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.
  3. For 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.
  4. 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.
6
Applicative

In this chapter

Two shapes of "and then"

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.

Fail fast with map2

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<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.

Why a monad cannot accumulate

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.

Accumulating, in FxDart

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> plus ap : F<A → B> × F<A> → F<B> (a function inside the structure, applied to a value inside the structure). map2 and ap are interdefinable, and map2 reads 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: pure adds nothing, and application is associative in the same way composition is. Every monad is an applicative (map2 via flatMap); the converse fails, and this chapter's validation is the standard counterexample.

Choosing between them

You needUseBecause
Step 2 needs step 1's valueflatMap / either scopeThe dependency is real
Steps are independent, first failure is enoughmap2Cheapest, and short-circuits
Steps are independent, report every failurezipOrAccumulate / accumulateOnly the applicative shape can
Steps are independent and slowApplicative + concurrencyIndependence 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.

When this earns its keep

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.

Exercises

  1. 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.
  2. Write map2 for Either using only flatMap and map. Then explain why the version you wrote cannot accumulate errors, in one sentence about types.
  3. In the 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?
  4. Is Set an applicative? What would map2 mean, and does it match your intuition about "combining two sets"?

Solutions

  1. 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.
  2. 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.
  3. With 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.
  4. Yes: 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.
7
Monad, in anger

In this chapter

Functions that return boxes do not compose

Chapter 4 composed A → B with B → C and got A → C. Try the same with steps that can fail:

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.

The pyramid, and four ways out

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:

LanguageSyntaxWhat the compiler emits
Haskelldo { id <- parseId raw; … }>>= chain
Scalafor { id <- parseId(raw) } yield …flatMap/map chain
Kotlin (Arrow)either { val id = parseId(raw).bind() }a scope with a non-local exit
Darteither((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 monad

Dart 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 single flatMap for 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: eitherAsync gives you a Raise scope inside an async body, so await handles time and r.bind handles 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.

One monad at a time

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.

When this earns its keep

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).

Exercises

  1. Write kleisli for Future — compose A → Future<B> with B → Future<C>. Which existing Dart method is it a thin wrapper around?
  2. The Kleisli identity arrow for 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.
  3. Rewrite the 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?
  4. await flattens Future<Future<T>>. What does that tell you about Future.then's type signature, compared with the map of Chapter 5?

Solutions

  1. 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.
  2. 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.
  3. The 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.
  4. 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.
8
Monoid and semigroup

In this chapter

The smallest useful algebra

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.

What each law buys

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]);}

Commutativity is a different law

Associativity says grouping does not matter. Commutativitya + 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 semigroup

Chapter 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 A and B are 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 with identity as the unit — which is the sentence hiding inside "a monad is a monoid in the category of endofunctors": flatten is the combine, of is the identity, and the three monad laws of Chapter 1 are these two laws in disguise.

When this earns its keep

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.

Exercises

  1. Is max a semigroup on int? A monoid? What would the identity element have to be, and does Dart have it?
  2. Give a monoid whose empty is not the "obviously empty" value — that is, where a reader would guess wrong.
  3. 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?
  4. Both Either accumulation and Future.wait combine independent results. Which monoid is Future.wait using, and what does it do with failures?

Solutions

  1. Yes and yes. 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.
  2. Several: 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.
  3. It is not associative — it is not even the right shape, since the seed type 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).
  4. 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.
9
Traverse

In this chapter

The shape you keep hand-rolling

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.

Why it needs an applicative, not just a functor

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:

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.

The async twin

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 container T and any applicative F — trees, maps, and Option are traversable too. It has two laws (identity and composition, like the functor's) and one famous corollary: traverse with the identity applicative is just map, and with the constant applicative it is fold. map, fold and traverse are 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.

The cost of not having it generically

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.

When this earns its keep

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.

Exercises

  1. What is sequence on an empty list — for Either, and for Future? Which law of Chapter 8 decides the answer?
  2. 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?
  3. You have 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.
  4. 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.

Solutions

  1. 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.
  2. They are the same work; 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.
  3. Yes, but the interesting case is 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.
  4. Right: a handful of fast local computations; a fan-out where the remote side is explicitly built for parallel load. Wrong: any rate-limited or paid API (unbounded fan-out gets you throttled or billed), and any list whose length is user-controlled — Future.wait over a 100k-element list opens 100k sockets, and the failure mode is your process, not theirs.
10
The missing floor

In this chapter

Kinds

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 * → *.

ThingKindComplete?
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.

The line where Dart stops

// 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.

What other languages do

🎓 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 traverse instead of seven, one sequence, 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.

The bill, counted

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.

When this matters to you

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.

Exercises

  1. What is the kind of Map? Of Map<String, dynamic>? Of a hypothetical Traverse interface?
  2. Extend the Kind encoding above to Either and write flatMap for it. How many casts do you need, and where would a wrong one blow up?
  3. 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.
  4. Dart does let you write T extends Comparable<T>. Why is that not a counterexample to this chapter?

Solutions

  1. 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.
  2. Two casts minimum — one to unwrap 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, _>.
  3. No. Such a function needs a parameter of kind * → * ("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.
  4. 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.
Part III · Evaluation
Laziness, pull versus push, and concurrency as an effect.
11
Laziness

In this chapter

Two kinds of operator

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.

The cost model

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:

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]);}

Laziness cannot change meaning

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:

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 where clauses 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, and fx(xs).map(f) composed twice is a plan, while let y = f x is already a thunk.

The second hazard: single-use sources

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.

When this earns its keep

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.

Exercises

  1. 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?
  2. Predict the output of a chain that calls peek(print) before take(2) over a ten-element source. How many lines print, and why?
  3. Write a chain whose callbacks run twice by accident. Then fix it two different ways.
  4. fx(range(1, 1000000)).map(expensive).first — how many times does expensive run? What if .first is replaced by .last?

Solutions

  1. It wins as soon as a stage discards work the eager version has already done — 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.
  2. Two lines. 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.
  3. Any chain assigned to a variable and consumed by two terminals, as in the listing above. Fix one: 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.
  4. Once with .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.
12
Pull and push

In this chapter

Who calls whom

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 paceconsumerproducer
Backpressurefree — just do not askmust be arranged
Stop earlystop pullingcancel a subscription
Timenot modelledinherent
Natural fitcollections, files, paged APIsUI 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.

Backpressure is the practical difference

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.

Why FxDart is not built on Stream

FxDart'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.

The bridges

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 of IEnumerable". The duality also predicts which operators are hard on each side: zip is easy on pull (ask both, wait for both) and needs buffering on push, while debounce is natural on push (it is about elapsed time) and meaningless on pull, where nothing happens between requests.

When each earns its keep

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.

Exercises

  1. 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?
  2. Why is debounce unavailable on a pull chain? Describe what it would even mean, and which part is incoherent.
  3. A paged HTTP API returns 100 rows per request. Model it both ways, then say which one makes "stop after the first match" cheaper — and by how many requests.
  4. 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?

Solutions

  1. 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.
  2. 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.
  3. Pull: an 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.
  4. 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.
13
Concurrency as an effect

In this chapter

One word, one guarantee

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.

The back-channel

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:

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.

Order, and what it costs to keep

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.

Two ways to be wrong

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.

When this earns its keep

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.

Exercises

  1. Six 40ms fetches at concurrent(3) took ~90ms. Predict the time at concurrent(6) and at concurrent(2), then run it.
  2. Why does concurrent(n) placed after map(fetch) affect fetch at all? Answer in terms of the direction of the request.
  3. Rewrite the balance example so the answer is deterministic without reducing the concurrency. What did the fix change about where state lives?
  4. A downstream .chunk(10) follows a concurrentPool(4). What breaks, and would concurrent(4) have the same problem?

Solutions

  1. 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.
  2. Because the request travels upstream. 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.
  3. Return the delta from each callback and fold afterwards: .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.
  4. Nothing breaks mechanicallychunk 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.
14
What the abstractions cost

In this chapter

The numbers

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 scaleCases
Tie (within 5% or 0.6ms)38
Hand-written Dart faster12
FxDart faster3

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:

CaseRatioWhat it means
top-expenses0.27×pipeline nearly 4× faster
price-drop-detection0.52×pipeline 2× faster
smoothed-zone-changes2.23×pipeline 2.2× slower
anomaly-context1.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.

Mechanism 1 — per-element indirection (costs you)

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.}

Mechanism 2 — allocation (costs both, differently)

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.

Mechanism 3 — work refused (pays you)

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 measurement rules that keep this honest

The suite's own rules, worth copying:

🎓 Big-O is unchanged; constants are not. None of this affects asymptotic complexity: a lazy filter + map + fold is O(n) exactly like the loop, and top-expenses is 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.

How to decide for your own code

  1. Assume a tie. Two-thirds of real tasks are one, and readability is then the only remaining criterion.
  2. Look for refused work. Any take, first, find, or early-exiting any over a large source is a reason to expect the pipeline to win.
  3. Look for intermediates. Count them on both sides; the side with more loses.
  4. Measure the actual case, twice. With a tie band, in AOT, at the size your program really sees.
  5. Then choose. A 6% median cost is a fair price for code your team can read — and it is not a price you should pay in the inner loop of a frame renderer.

When to reach for the loop

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.

Exercises

  1. A pipeline has five stages over 1M elements and finishes with .first. Which of the three mechanisms dominates, and what is the expected ratio against a loop that does the same job?
  2. Why is peak RSS often a better discriminator than elapsed time when comparing two versions of a grouping task?
  3. The suite calls a difference under 5% a tie. Construct a case where a 4% difference genuinely matters, and say what you would have to change about the measurement to detect it reliably.
  4. 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.

Solutions

  1. Refused work dominates. .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.
  2. Because grouping is where intermediates hide. Both versions may take the same time on a warm machine with plenty of RAM, while one holds every group in memory at once and the other streams; RSS shows that difference immediately and predicts which one falls over on a larger input.
  3. A tight render loop at 120fps has an 8.3ms budget, so 4% of a 5ms frame is a third of a millisecond of headroom — real. To detect it you need many more iterations per measurement (to lift the signal above timer resolution), a quiet machine, and paired interleaved A/B runs rather than one-after-another runs, so that drift affects both sides equally.
  4. Many cheap stages (a sliding window plus a comparison plus a map) over uniform numeric data, with everything consumed — no stage refuses work, and per-element indirection is paid several times per element with very little real computation to amortise it against.
Part IV · Failure
Typed errors, short-circuiting, accumulation, and the honest boundary with exceptions.
15
The Raise scope

In this chapter

What either is

Chapter 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.

Delimited continuation, not desugaring

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 forany monad the types can namethe effects the library wrote
Needshigher-kinded typesnothing special
Failure exitreturning a short-circuited valuenon-local jump, caught at the boundary
Composes with asyncneeds a transformernaturally — eitherAsync
Extensible by youyes, by defining a monadno

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.

The three scope flavours

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.

The leak rule

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/reset in Scheme, Cont in Haskell, algebraic effect handlers in OCaml 5 and Koka are all this machinery. Raise uses 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.

When this earns its keep

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.

Exercises

  1. Rewrite the first listing as a flatMap chain. Which version makes it easier to add a guard — "fail if the value drops below 3" — between steps?
  2. What does either return when the block throws a genuine exception rather than raising? Try it, and explain why that is the right default.
  3. Why can a scope not be resumed — that is, why is there no r.recover(...) that continues the block after a failure? Answer in terms of the mechanism.
  4. 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??

Solutions

  1. The chain is 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.
  2. The exception propagates out of 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.
  3. Because the escape is implemented as a throw: by the time 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 Eitherresult.fold(...) or getOrElse.
  4. Its 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.
16
Either as a railway

In this chapter

Two tracks

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:

OperationGreen track (Right)Red track (Left)
map(f)applies fpasses through
flatMap(f)applies f, which may divertpasses through
mapLeft(g)passes throughapplies g
fold(l, r)applies rapplies 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.

Error types have to compose too

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.

Recovery, and where totality ends

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.

Either in a pipeline

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 Either used 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.

When this earns its keep

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.

Exercises

  1. map on a Left does nothing. Which functor law forces that, and what would break if a library "helpfully" ran the function anyway?
  2. Write getOrElse for Either in terms of fold. Then write orElse, which takes a fallback Either rather than a fallback value.
  3. You have 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.
  4. separateEither returns (errors, values). Why that order, and what consequence does the choice have for reading code at a glance?

Solutions

  1. The identity law: 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.
  2. 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.
  3. 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.
  4. It matches (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.
17
Accumulating failure

In this chapter

The product question

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.

The four tools

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:

ShapeTool
2–5 named, independent fieldszipOrAccumulate2..5
One rule, many itemsmapOrAccumulate
Already have EithersflattenOrAccumulate / .flattenOrAccumulate()
More than five branches, or dependent rulesaccumulate

Independent, then dependent

The rule that makes accumulation correct is Chapter 6's distinction, and it has a precise API shape:

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.

A form, end to end

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.

The rules that keep it honest

  1. One branch per independent concern. A branch that validates two fields cannot report on the second if the first failed.
  2. Raise more than once in a branch when it makes sense. A branch may contribute several errors; age above raises up to two.
  3. Never read .value inside an independent branch. That is what dependent is for, and reading early detonates the whole scope.
  4. Order the errors the way the user reads the form. Branch order is report order, and it is free to get right.
  5. Do not accumulate consequences. If step B is meaningless when A failed, B belongs in dependent or in a fail-fast scope, not in a branch.

🎓 Why there is no Validated type. Arrow 1.x had one — a separate Validated<E, A> whose applicative accumulated and which you converted to and from Either at every boundary. Arrow 2.x deleted it, and FxDart never had it: the same effect is available as a scope over Either<Nel<E>, A>, which means one result type in your domain signatures instead of two, and no toEither() calls scattered through the code. The theory lost nothing — Validated was only ever Either with 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.

When this earns its keep

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.

Exercises

  1. In the signup form, move the "pro requires 21+" rule from dependent to accumulating and predict the output for {'age': 'x', 'plan': 'pro'}.
  2. 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?
  3. Why is the error type Nel<String> and not List<String>? Give the state that List admits and Nel forbids.
  4. A branch calls an API. Should it be accumulating or dependent, and what changes if two branches call the same API?

Solutions

  1. It would run, read 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.
  2. Every failure is retained until the end, so worst case you hold 10,000 error strings — fine. At 10M rows it is not: you would stream and report, capping the collected errors (the first N, plus a count) or writing them to a rejects file as they occur. Accumulation is bounded by the failure count, and that is the number to sanity-check before choosing it.
  3. 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.
  4. 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.
18
The honest boundary

In this chapter

Three channels

Dart lets a function fail in three ways, and they are not interchangeable.

ChannelType saysCaller mustCarries
thrownothingnothing (unchecked)any object + stack trace
A?it might be absenthandle nullno reason
Either<E, A>it might fail, with Ehandle both sidesa 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.

What typed errors cannot promise

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 are E, 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.

Converting at the edges

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 — while Exception signals a condition a correct program may still hit (FormatException, IOException). That maps neatly onto this chapter: Error should never be caught and converted into a Left, because doing so hides a bug; Exception is a fine candidate for eitherCatching. When you write a library, following the convention is what lets your callers make this distinction at all.

The nullable middle ground

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.

When this earns its 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.

Exercises

  1. Classify these as throw / 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.
  2. int.parse throws and int.tryParse returns null. Which channel would Either have given, and what would it have had to invent?
  3. Why is eitherCatching a separate function rather than the default behaviour of either? Describe the bug that would follow from the other choice.
  4. A function returns Either<E, A> but also throws on some inputs. How would you discover that, and what would you change — the code or the signature?

Solutions

  1. JSON parse failure: 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.
  2. It would have given 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.
  3. Because folding every throw into a 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.
  4. Discover it with tests over the failing inputs, or by reading for calls that can throw (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.
Part V · Laws and lineage
Equational reasoning, category theory in the right dose, and where these ideas came from.
19
Equational reasoning

In this chapter

Refactoring is substitution

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:

LawEquation
Functor compositionm.map(f).map(g) = m.map(g ∘ f)
Functor identitym.map(id) = m
Monad left identityof(a).flatMap(f) = f(a)
Monad associativitym.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.

A worked transformation

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:

  1. map((n) => n) is map(id) → delete it. Functor identity.
  2. map(f).map(g)map(g ∘ f), giving map((n) => n * 2 + 1). Functor composition.
  3. Nothing moved across the filter, because the predicate reads the mapped value — that reordering would need a precondition we do not have.
  4. The 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.

Laws as tests

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');}

The preconditions

Equational reasoning works when equals really are equal, and there are exactly three ways for that to fail:

  1. Impurity. If a callback logs, mutates, or reads the clock, two expressions with the same value are not the same program. Chapter 2.
  2. The wrong equality. Laws are stated over an equality: structural for 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.
  3. A type that does not obey. 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/build fusion, 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.

When this earns its keep

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.

Exercises

  1. Is 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.
  2. Justify xs.map(f).toList().map(g).toList()xs.map((x) => g(f(x))) .toList() step by step. Which step also changes the cost?
  3. Extend the property test to check that map and flatMap agree: m.map(f) == m.flatMap((x) => Either.right(f(x))). Which law makes this true for every lawful monad?
  4. Your 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.

Solutions

  1. Not in general. It holds only when 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.
  2. Delete the intermediate 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.
  3. It is the definition of 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.
  4. 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.
20
Category theory, in the right dose

In this chapter

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, in four lines

A category is:

  1. a collection of objects;
  2. for each pair of objects, a collection of morphisms (arrows) between them;
  3. a composition operation: given f : A → B and g : B → C, an arrow g ∘ f : A → C;
  4. an identity arrow 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.

Functors, again

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.

Natural transformations

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.

The monad, in its original clothes

A monad on a category C is a triple (T, η, μ):

satisfying three coherence conditions:

μ ∘ T(μ)  = μ ∘ μ_T          (associativity)μ ∘ T(η)  = id  =  μ ∘ η_T   (unit, both sides)

Translated:

Category theoryDart
Tthe type constructor — Either<E, _>, List, Future
η (unit)of / Either.right / [x] / Future.value
μ (join)flattenList<List<A>> → List<A>
flatMap(f)μ ∘ T(f) — map, then flatten
the coherence conditionsChapter 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.

The famous sentence

A monad is a monoid in the category of endofunctors.

You now have every piece:

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 flatMap and map come in pairs) and free monads (which explain interpreters); neither is needed for anything in Part I to IV.

When this earns its keep

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.

Exercises

  1. Show that Dart's types and functions really do satisfy the two category laws. What are you actually relying on about compose2?
  2. Is 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.
  3. first maps List<A> to A?. Is it natural? What about sortBy, which maps List<A> to List<A>?
  4. Write out flatMap as μ ∘ T(f) for Either<E, _>. What is μ for Either, concretely?

Solutions

  1. Associativity: 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.
  2. Yes. 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.
  3. 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?
  4. μ : 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.
21
Lineage

In this chapter

Five languages, one set of ideas

Introduced / popularisedLost in translation
Haskell (1990)typeclasses, monads as an interface, donothing — it is the source; the cost is the language itself
Scala (2004)monads in an OO language, for-comprehensions, Catsimplicit resolution complexity; two syntaxes for everything
Kotlin + Arrow (2017)typed errors without HKTs, Raise, context receiversgeneric abstraction over effects — deleted in Arrow 2
FxTS (2021)lazy pipelines, concurrent(n), in TypeScriptlaws as a stated contract; TS types are erased at runtime
FxDart (2025)the FxTS model + Arrow's errors, in Dartcurried 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.

Haskell: where the interface came from

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: monads meet objects

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.

Kotlin's Arrow: typed errors without the tower

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.

FxTS: the pipeline half

Everything in Parts I and III comes from the other parent. FxTS brought:

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.

FxDart: what it is, exactly

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.

Reading the ancestors

When this earns its keep

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.

Exercises

  1. Arrow 2 deleted its Kind encoding and its Validated type. What did each deletion cost, and what did it buy?
  2. FxTS's 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.
  3. Scala solves Future + Either with EitherT; FxDart solves it with eitherAsync. Which one generalises to a third effect, and what does the other one do instead?
  4. Which chapters of this book would need to be rewritten if Dart gained higher-kinded types tomorrow? Which would not change at all?

Solutions

  1. Deleting 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.
  2. FxTS can hold 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.
  3. 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.
  4. Chapter 10 would be rewritten (it is about the absence), and Chapter 9's "four spellings" section would collapse to one. Chapters 1, 5, 6, 7, 8 and 19 would not change at all — the definitions and laws are language-neutral, which is the whole reason the theory was worth learning separately from the library.
22
When not to use any of this

In this chapter

The honest position

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.

Five shapes where the loop wins

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.

The costs nobody lists

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.

The checklist

Before reaching for the pipeline vocabulary:

  1. Does something get discarded? take, first, a selective filter, an early exit. If yes, laziness is earning its keep (Chapter 11).
  2. Is there waiting? Independent IO that could overlap. If yes, concurrent(n) is worth the chain on its own (Chapter 13).
  3. Are failures data? Multiple fallible steps whose reasons the caller needs. If yes, typed errors pay (Chapters 15–18).
  4. Would the loop need a comment? Nested grouping, three accumulators, a "seen" set — if the imperative version needs a paragraph, the pipeline is usually shorter and clearer.

Three or four yeses: use the tools. One yes: use the tools for that part only. Zero: write the loop, and do not apologise.

What to keep regardless

Even if you never use FxDart again, four things from this book survive:

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.

When this chapter earns its keep

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.

Exercises

  1. Take a pipeline from your own code and apply the checklist. How many yeses? If fewer than two, rewrite it as a loop and compare the diff.
  2. Write the worst reasonable pipeline for "sum the even numbers in a list", then the loop. Which is shorter? Which would you rather debug at 2am?
  3. Chapter 14 found the pipeline faster in three of 53 cases. What did those three have in common, and does your hot path have it?
  4. Name a piece of code in your project where typed errors would be worse than an exception, and say precisely why.

Solutions

  1. Most existing pipelines score two or three, which is why they were written. The ones that score zero are usually a map over a small fixed list that could be a for — and rewriting them is a small, real improvement, not a defeat.
  2. The loop is shorter and easier to debug: 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.
  3. All three refused work: they used 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.
  4. Anything the caller cannot act on: a failed assertion about an internal invariant, a corrupted cache file at startup, a programming mistake in an argument. Modelling those as 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.
Appendices
Reference: every term, every law, and where to read more.
Appendix A · Glossary

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.

The tower

TermAlso calledIn Dart / FxDartCh.
Functorany type with a lawful map5
Applicativeapplicative functormap2, zipOrAccumulate, Future.wait6
Monadany type with of + lawful flatMap1
Monoida fold seed plus an associative combine8
Semigroupassociative combine, no identity — Nel8
Traversablesequence, mapOrAccumulate, Future.wait9
Kleisli compositionmonadic composition, >=>(a) => f(a).flatMap(g)7
Natural transformationa generic conversion that ignores contents20
Higher-kinded typeHKT, type constructor polymorphismnot expressible in Dart10

Operations

TermAlso calledIn Dart / FxDartCh.
ofpure, return, unit, ηEither.right, [x], Future.value, fx([x])1
mapfmap, <$>map, Future.then5
flatMapbind, >>=, chainflatMap, expand, Future.then, r.bind1
joinflatten, μflat(), expand(id)20
map2zipWith, liftA2map2, zipOrAccumulate26
traversemapOrAccumulate, .map(f).sequence()9
sequencesequenceEither, Future.wait9
foldcatamorphism, reduce with seedfold, Either.fold8

Evaluation

TermAlso calledIn Dart / FxDartCh.
Lazydeferred, non-strictany Fx stage — nothing runs until a terminal11
Terminal operatorconsumer, sinktoList, each, fold, first, sum11
Pullinteractive, Iterable-shapedIterable, FxAsyncIterable12
Pushreactive, observableStream, FxEvents12
Backpressureflow controlnot asking for the next value12
Fusionstage fusion, deforestationone pass through a whole chain5
Concurrencyconcurrent(n), mapConcurrent — overlapping waits13
Parallelismisolates — overlapping computation13

Failure

TermAlso calledIn Dart / FxDartCh.
EitherResult, Validation, disjoint unionEither<L, R>, Left, Right16
Raise scopecontext receiver scope, effect scopeeither((r) { … }), r.bind, r.ensure15
Delimited continuationshift/reset, effect handlerthe non-local exit inside either15
Short-circuitfail-fastthe first Left ends the chain16
Accumulationfail-slow, applicative validationaccumulate, zipOrAccumulate, mapOrAccumulate17
NonEmptyListNelNonEmptyList<E> — extension type over List8
Monad transformerEitherT, OptionTnot usedeitherAsync instead7

Foundations

TermAlso calledIn Dart / FxDartCh.
Pure functionsame inputs, same output, nothing observable2
Referential transparencysubstitutabilityreplacing a call with its result2
Effectside effectanything observable besides the return value2
Total functiondefined for every input — fold is, reduce is not8
Product typerecord, tuple, struct(A, B), class fields3
Sum typetagged union, variant, coproductsealed class + switch3
Algebraic data typeADTsums and products together3
Currying.curried / .uncurried4
Partial applicationa closure capturing some arguments4
Higher-order functiontakes or returns a function4
Equational reasoningreplacing equals with equals, by law19
Lawproperty, contractan equation instances must satisfy1, 5, 8
Categoryobjects + composable arrows + identities20

Names that mean the same thing

A short decoder for cross-language reading:

Appendix B · Law reference

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.

Functor — Chapter 5

LawEquation
Identitym.map((x) => x) == m
Compositionm.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).

Monad — Chapter 1

LawEquation
Left identityof(a).flatMap(f) == f(a)
Right identitym.flatMap(of) == m
Associativitym.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.

Applicative — Chapter 6

LawEquation
Identityof(id).ap(m) == m
Homomorphismof(f).ap(of(a)) == of(f(a))
Interchangeu.ap(of(a)) == of((f) => f(a)).ap(u)
Compositionof(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.

Monoid / semigroup — Chapter 8

LawEquation
Associativity(a + b) + c == a + (b + c)
Left identityempty + a == a
Right identitya + 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.

Traverse — Chapter 9

LawStatement
Identitytraversing with the identity applicative is map
Compositiontraversing with two applicatives in sequence == traversing once with their composition
Naturalitya natural transformation commutes with traverse

Licenses: choosing where to traverse in a chain; swapping fail-fast for accumulating without touching the per-element function.

Natural transformation — Chapter 20

LawEquation
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.

Category — Chapter 20

LawEquation
Associativity(h ∘ g) ∘ f == h ∘ (g ∘ f)
Identityid ∘ f == f == f ∘ id

Licenses: extracting or inlining any composition of pure functions, including pipeline stages.

The preconditions behind all of them

  1. Purity. Every law above is stated over values; an effect makes two equal values into two different programs (Chapter 2).
  2. The right equality. Structural for Either and Money; observational for Future; set-equality for Set. A law can hold under one and fail under another (Chapter 19).
  3. Someone checked. A type's laws are a claim. Until there is a property test, they are a comment (Chapter 19).

Testing template

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');}
Appendix C · Further reading

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.

Inside this project

SourceGood forDifficulty
FxDart 101 tutorialsthe API surface, one function at a time, with runnable demoseasy
Dart vs FxDart (52 examples)whether a pipeline is the right tool for a given task, with verdictseasy
RxDart vs FxDartChapter 12's pull/push decision, applied to 50 real problemseasy
WHY_CURRIED.mdthe reasoning behind Chapter 4 — what a port owes its sourcemoderate
ARROW_MIGRATION_BLOCKER.mdthe HKT wall of Chapter 10, documented as it was hitmoderate
benchmark/AUTHORING.mdhow the Chapter 14 numbers are produced, and how to add a casemoderate

The ancestors' documentation

SourceGood forDifficulty
Arrow (Kotlin) — typed errors guidePart IV's specification, effectively; the Raise scope, accumulation, and the design rationalemoderate
FxTS docsthe operator catalogue and concurrent(n); names map onto FxDart almost exactlyeasy
Cats (Scala) — typeclass docsthe tower stated generically: functor → applicative → monad → traversehard without Scala
Haskell Data.Functor / Control.Monadthe laws in their original form, terselyhard

Papers and talks worth the time

Books

What to read for a specific question

You want to knowGo 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

A closing note on how to read theory

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.