Typed errors
Write straight-line code that fails with a typed error. The approach of Kotlin's Arrow 2.x, ported to Dart.
Either ·
either & the Raise scope ·
nullable ·
NonEmptyList ·
accumulation ·
Either × pipelines
From Kotlin Arrow to Dart
In Kotlin Arrow, an either { } block turns a chain of
fallible steps into straight-line code — each .bind() either
unwraps a success or short-circuits the whole block with the failure:
// Kotlin Arrow
fun getResult(): Either<Failure, SuccessData> = either {
val user = findUser(userId).bind()
val order = findOrder(user.id).bind()
val total = calculateTotal(order).bind()
SuccessData(user, order, total)
}
FxDart gives you the same shape in Dart:
// FxDart
Either<Failure, SuccessData> getResult() => either((r) {
final user = r.bind(findUser(userId));
final order = r.bind(findOrder(user.id));
final total = r.bind(calculateTotal(order));
return SuccessData(user, order, total);
});
Both replace the nested flatMap pyramid you would otherwise
write:
// What it replaces
Either<Failure, SuccessData> getResult() =>
findUser(userId).flatMap((user) =>
findOrder(user.id).flatMap((order) =>
calculateTotal(order).map((total) =>
SuccessData(user, order, total))));
The two differences from Kotlin are Dart realities: the scope is an
explicit parameter (r) because Dart has no lambda receivers,
and async has its own builder (eitherAsync) because Dart has
no inline. Internally this is not flatMap chaining:
like Arrow, r.bind on a failure throws a private,
scope-tagged signal that the builder catches at the boundary — which is
why early returns, loops, and ifs all just work inside the
block, and why nested builders never capture each other's errors.
The scope vocabulary
Everything hangs off the r the builder hands you — type
r. and discover it all:
Either<String, int> parsePort(String raw) => either((r) {
final n = r.ensureNotNull(int.tryParse(raw), () => '"$raw" is not a number');
r.ensure(n > 0 && n < 65536, () => '$n is out of range');
return n;
});
switch (parsePort('8080')) {
case Right(:final value): print('listening on $value');
case Left(:final value): print('bad config: $value');
}
r.bind(either)/r.bindAll(eithers)— unwrap or short-circuit.r.ensure(cond, () => err)— the typed-errorrequire.r.ensureNotNull(x, () => err)— returns non-null, with promotion.r.recover(block, onRaise)— handle a raised error in a nested scope.r.withError(transform, block)— adapt another error type into this scope.r.raise(err)— short-circuit directly; returnsNever.
eitherAsync is the async twin (raise only in the same awaited
chain); nullable/nullableAsync are the
nullable-first twins that return T? instead of an
Either — FxDart stays nullable-first, so there is no
Option type.
Deep dive: either & the
Raise scope → ·
Deep dive: nullable →
Accumulate every failure, not just the first
Validation wants all the errors, not the first one. This is
Arrow's replacement for a separate Validated type:
final user = either<Nel<String>, User>((r) => r.accumulate((acc) {
final name = acc.accumulating((r) => validateName(r, input));
final age = acc.accumulating((r) => validateAge(r, input));
return User(name.value, age.value); // all errors reported together
}));
r.accumulate runs every branch and concatenates all failures
into a NonEmptyList (Nel) — a zero-cost
extension type that cannot be empty. Fixed-arity conveniences
r.zipOrAccumulate2..5 cover the common cases, and
r.mapOrAccumulate(items, transform) validates a whole
collection fail-slow. r.bindNel lets one branch contribute
several errors at once; someEither.toEitherNel() bridges a
fail-fast value into an accumulating scope.
Deep dive: accumulation → ·
Deep dive: NonEmptyList →
Fused with pipelines
This is the part neither Arrow nor any Dart FP library has: typed errors fused with FxDart's lazy, concurrency-aware pipelines.
// Validate 500 records, 8 at a time, and keep EVERY failure — in order.
final result = await fxStream(records)
.mapOrAccumulate<String, User>((r, rec) async {
final parsed = r.ensureNotNull(tryParse(rec), () => 'bad record: $rec');
return await enrich(parsed);
}, concurrency: 8);
rights(), lefts(), separated(),
sequence() (fail-fast, stops pulling at the first
Left) and mapOrAccumulate() (fail-slow) are
eager terminals on fx()/async chains. Concurrent validation
rides the same concurrent(n) back-channel as the rest of
FxDart; each element runs in its own scope, so a failure in one element
can never leak into a sibling.
Deep dive: Either ×
pipelines →
Exceptions vs raised errors
The boundary is hard: raised errors are your domain's typed
failures; thrown exceptions are defects, and they propagate out
of either untouched. To capture a throw into an
Either, be explicit:
final parsed = Either.catching(() => jsonDecode(raw)); // Either<Object, dynamic>
final typed = Either.catchingWith(ParseFailure.new, () => jsonDecode(raw));
Deep dive: Either.catching lives on the
Either page →
toList() or use the eager
terminals above; a deferred raise fails loudly with
RaiseLeakedError. (2) Never bare-catch inside a
raise block — use catching/catchingAsync, which
always let the short-circuit signal through (on Exception is
already safe: the signal is an Error).
Curious why this page is called typed errors instead of a functional-programming keyword like Monad? The naming rationale →