Skip to content

prefer_do_notation

v1.0.0WarningConfigurablefpdart

This rule flags flatMap callbacks nested three or more levels deep on an fpdart type.

Do is sugar over flatMap with identical semantics — same short-circuit on the first None/Left, same order of effects. The difference is entirely shape.

Nested callbacks indent one level per step and push each step’s value into a closure, so by the third the reader is tracking which ) closes what, and the trailing ))) has to be counted rather than read. A Do block is flat regardless of step count, and every extracted value is an ordinary local that later steps just refer to by name.

See also: fpdart: Do notation

Three steps, each one wrapping the next in a callback. By the third the reader is counting brackets to work out which ) closes what:

TaskEither<String, String> readConfig(String path) => TaskEither.of('{}');
TaskEither<String, int> parsePort(String config) => TaskEither.of(8080);
TaskEither<String, String> connect(int port) => TaskEither.of('ready');
TaskEither<String, String> start(String path) => readConfig(path).flatMap(
(config) => parsePort(config).flatMap(
(port) => connect(port).flatMap(
(status) => TaskEither.of('$status on $port'),
),
),
);

Every extracted value is an ordinary local, and the block stays flat however many steps it grows to:

TaskEither<String, String> start(String path) => TaskEither.Do(($) async {
final config = await $(readConfig(path));
final port = await $(parsePort(config));
final status = await $(connect(port));
return '$status on $port';
});

A synchronous Option pipeline is the same shape without the await:

Option<String> label(Option<String> first, Option<String> last) =>
Option.Do(($) {
final given = $(first);
final family = $(last);
return '$given $family';
});

Only nesting is counted, not chaining. a.flatMap(f).flatMap(g).flatMap(h) is already flat and reads fine — it is a.flatMap((x) => b.flatMap((y) => ...)) that grows sideways.

Exactly one diagnostic is reported per nest, on the outermost call, because the whole nest is one shape with one fix. Reporting each level would produce a diagnostic per step.

There is no quick fix, but there is an assist: put the cursor on any flatMap in the nest and pick “Convert to Do notation”. It generates the block, takes each step’s name from that callback’s own parameter, and offers every generated name as a linked edit position — so accepting the assist drops the cursor on the first name with the rest reachable by Tab.

The inverse assist, “Convert to flatMap chain”, is offered with the cursor anywhere in a Do block, for when this rule’s preference is the wrong call for a particular pipeline.

It converts only the straight-line shape: a run of final <name> = $(...) bindings followed by a single return. A block that branches, loops, or extracts inside a larger expression is declined rather than half-translated.

A plain return x becomes Type.of(x) on the way out, since Do lifts its own result and a chain does not; await $(...) in a TaskEither.Do loses the await, which belonged to the block rather than to the step.

analysis_options.yaml
many_lints:
rules:
prefer_do_notation:
max_flat_map_depth: 2
Option Type Default Description
max_flat_map_depth int 3 How deeply flatMap callbacks may nest before the outermost is reported. 2 pushes a team toward Do almost immediately; 4 reserves it for genuinely long pipelines
max_flatmap_depth int 3 Deprecated compatibility alias for max_flat_map_depth

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

To turn it off:

many_lints.yaml
rules:
prefer_do_notation: false

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