Skip to content

prefer_chaining_over_intermediate_run

v1.0.0WarningConfigurablefpdart

This rule flags a function body that calls .run() on two or more fpdart pipelines instead of chaining them and running once.

flatMap carries the error channel through the whole pipeline. Any step that fails short-circuits the rest, and the failure handling is written once, at the fold — the compiler enforces what the manual version leaves to discipline.

Running each step separately throws that away. Every intermediate result has to be unwrapped by hand, every failure branch rebuilt, and the nesting grows one level per step. It is also where failures get quietly lost: one forgotten if and the body continues with a value that was never produced, or a rebuilt Left swallows the original error.

That imperative shape is the exact thing TaskEither exists to delete. A body with several .run() calls is usually a chain that was never joined up.

See also: Why chaining beats manual result handling

Each step is run on its own, so every result is unwrapped by hand and every failure branch rebuilt:

TaskEither<String, int> currentUserId() => TaskEither.of(7);
TaskEither<String, int> latestOrderId(int userId) => TaskEither.of(42);
TaskEither<String, double> invoiceTotal(int orderId) => TaskEither.of(19.99);
Future<Either<String, double>> total() async {
final userId = await currentUserId().run();
if (userId case Right(value: final id)) {
final orderId = await latestOrderId(id).run();
return switch (orderId) {
Right(value: final order) => await invoiceTotal(order).run(),
Left() => Left('no order'), // the original failure is lost
};
}
return Left('no user');
}

Chain the steps and run once. The signature is unchanged, so no caller has to move:

Future<Either<String, double>> total() => currentUserId()
.flatMap(latestOrderId)
.flatMap(invoiceTotal)
.run();

Flat regardless of step count, no manual unwrapping, and the original failure propagates untouched.

Better still, hand the caller the unrun pipeline and let the boundary that renders the outcome run it. That does change the return type, so it is a change to make on purpose:

TaskEither<String, double> total() =>
currentUserId().flatMap(latestOrderId).flatMap(invoiceTotal);

When the chain grows past two or three steps, Do notation reads better than nested flatMap.

A .run() inside a closure is not counted against the enclosing member, so two handlers each running their own pipeline are never reported:

void wire(void Function(Future<void> Function()) onTap) {
onTap(() async {
await currentUserId().run();
});
onTap(() async {
await latestOrderId(1).run();
});
}

The diagnostic lands on the member’s name, not on each .run() — the fix is one edit at that level.

No quick fix is offered: joining the steps means rewriting the body’s control flow, including deciding what each unwrapped branch was for.

analysis_options.yaml
many_lints:
rules:
prefer_chaining_over_intermediate_run:
min_sequence: 3
Option Type Default Description
min_sequence int 2 How many .run() calls a body may contain before it is reported. 1 reports every body that runs a pipeline at all, which suits a codebase that folds exclusively at the notifier boundary

This rule is in the opinionated preset. With a lower preset, enable it by name with prefer_chaining_over_intermediate_run: true.

To turn it off:

many_lints.yaml
rules:
prefer_chaining_over_intermediate_run: false

To keep the rule on but skip certain paths, use per-rule exclude.