Assists
An assist is a refactoring you invoke deliberately, by putting the cursor somewhere and opening the lightbulb menu — Ctrl + . on Windows/Linux, Cmd + . on macOS in VS Code, or Alt + Enter in the JetBrains IDEs.
The difference from a quick fix matters in practice:
| Quick fix | Assist | |
|---|---|---|
| Triggered by | a reported diagnostic | cursor position |
| Needs a rule enabled | yes | no |
| Offers “apply all in file” | yes | no |
Because assists are not tied to a diagnostic, they work even with preset: none and no rules turned on at all. That independence is also why some transformations ship as an assist rather than as a fix: when the result needs a human eye — reviewing generated names, or choosing a direction the rules do not prefer — an “apply all” would be the wrong tool.
Convert to collection-for
Section titled “Convert to collection-for”Put the cursor on a .map() call.
// Beforefinal doubled = numbers.map((e) => e * 2).toList();final halved = numbers.map((e) => e / 2).toSet();
// Afterfinal doubled = [for (final e in numbers) e * 2];final halved = {for (final e in numbers) e / 2};The trailing .toList() / .toSet() picks the literal that comes out; a bare .map() with no collector is left alone, since it is lazy and a collection literal is not.
Convert to Do notation
Section titled “Convert to Do notation”Put the cursor on any flatMap in a nested chain. Related rule: prefer_do_notation.
// BeforeOption<String> goShopping() => goToShoppingCenter().flatMap( (market) => market.buyBanana().flatMap( (banana) => market.buyApple().flatMap( (apple) => Option.of('$banana, $apple'), ), ), );
// AfterOption<String> goShopping() => Option.Do(($) { final market = $(goToShoppingCenter()); final banana = $(market.buyBanana()); final apple = $(market.buyApple()); return '$banana, $apple';});Each step’s name comes from that callback’s own parameter, and every generated name is offered as a linked edit position — accepting the assist drops the cursor on the first name with the rest reachable by Tab, so renaming is part of applying it rather than a follow-up chore.
The assist works from anywhere in the nest, not just the outermost call. An innermost Option.of(x) becomes a plain return x, because Do wraps the block’s result itself.
This is an assist and not a fix on purpose: the generated names are only as good as the original parameter names, so “apply all” would be one keystroke away from a file full of final a = ....
Convert to flatMap chain
Section titled “Convert to flatMap chain”The inverse of the above. Put the cursor anywhere in a Do block.
// BeforeOption<String> goShopping() => Option.Do(($) { final market = $(goToShoppingCenter()); final banana = $(market.buyBanana()); return '$banana'; });
// AfterOption<String> goShopping() => goToShoppingCenter().flatMap( (market) => market.buyBanana().flatMap( (banana) => Option.of('$banana'), ),);A plain return x becomes Type.of(x) on the way out, since Do lifts its own result and a chain does not. In an async block, await $(...) loses the await — it belonged to the block rather than to the step.
Only the straight-line shape converts: a run of final <name> = $(...) bindings followed by a single return. A block that branches, loops, or extracts inside a larger expression is declined outright rather than half-translated, because flatMap is a fixed chain of continuations — turning an if into one would mean duplicating everything after it into both arms. A Do block doing that much is one where Do is genuinely the better notation.
Convert to TaskEither / TaskOption
Section titled “Convert to TaskEither / TaskOption”Put the cursor on a function that returns Future<Either<L, R>>, Either<L, R>, Future<Option<T>> or Option<T>. The lightbulb entry names the concrete target — “Convert to TaskEither” or “Convert to TaskOption”.
Related rules: avoid_future_of_either, avoid_future_of_option — but only for the two Future<...> shapes. Both rules require a Future wrapper, so a bare Either or Option is never reported; those two conversions are offered on their own.
From Future<Either<L, R>> — TaskEither is the same thing with the laziness and the combinators kept:
// BeforeFuture<Either<Failure, User>> getUser(String id) async { return right(await api.get(id));}
// AfterTaskEither<Failure, User> getUser(String id) => TaskEither(() async { return right(await api.get(id)); });From Either<L, R> — for when a synchronous pipeline has to grow an await in the middle. Either cannot host one; TaskEither can. The body is transplanted whole, so every return left(...) / return right(...) already in it keeps working:
// BeforeEither<Failure, User> parse(String raw) { if (raw.isEmpty) return left(Failure.empty()); return right(User.fromJson(raw));}
// After — now an await can go anywhere insideTaskEither<Failure, User> parse(String raw) => TaskEither(() async { if (raw.isEmpty) return left(Failure.empty()); return right(User.fromJson(raw)); });Option works the same way, converting to TaskOption:
// BeforeOption<User> find(String raw) { if (raw.isEmpty) return none(); return some(User.fromJson(raw));}
// AfterTaskOption<User> find(String raw) => TaskOption(() async { if (raw.isEmpty) return none(); return some(User.fromJson(raw)); });A body that already delegates to another Future<Either> becomes TaskEither(() => src(id)), without a redundant async.
Functions with no written return type are declined, since the assist edits that type in place. So are generators — a single TaskEither cannot stand in for a stream of values.
Narrow a flatMap to what it actually does
Section titled “Narrow a flatMap to what it actually does”flatMap is the general chaining tool, so it is what you reach for when you are thinking about chaining rather than about which combinator fits. These five assists put the cursor on a flatMap and offer the narrower name.
Four of them are exact: fpdart declares the narrower combinator as the flatMap they replace, so the conversion changes the name and nothing else. The fifth, chainFirst, does not — see its warning below.
Each resolves the receiver’s type rather than matching the name flatMap, so an unrelated class with a method of that name is never offered an fpdart combinator.
Convert to andThen
Section titled “Convert to andThen”Put the cursor on a flatMap whose callback never reads its argument. Related rule: prefer_and_then.
resetter.reset().flatMap((_) => authRepository.logout())becomes
resetter.reset().andThen(authRepository.logout)A callback body that is a bare no-argument call becomes a tear-off; anything else keeps a thunk (andThen(() => seed(3))). The check is by element, not by the _ spelling — a named-but-unused parameter converts, and a used one is declined, because andThen discards the value.
Convert to map
Section titled “Convert to map”Put the cursor on a flatMap whose callback only re-wraps its result.
pipeline.flatMap((v) => TaskEither.right(transform(v)))becomes
pipeline.map(transform)Declined when the body branches: a conditional returning left on one side is a genuine flatMap, and map cannot fail.
Convert to filterOrElse
Section titled “Convert to filterOrElse”Put the cursor on a flatMap whose body is a right/left ternary.
pipeline.flatMap((v) => v.isValid ? TaskEither.right(v) : TaskEither.left(Invalid()))becomes
pipeline.filterOrElse((v) => v.isValid, (v) => Invalid())Both branch orders work; when the failure comes first the predicate is negated rather than the assist declining. Declined when the success branch returns anything other than the parameter, since that is a transform rather than a filter.
Convert to sequenceListSeq
Section titled “Convert to sequenceListSeq”Put the cursor on a reduce that chains each element onto the accumulator.
tasks.reduce((acc, t) => acc.flatMap((_) => t))becomes
TaskEither.sequenceListSeq(tasks)Always the Seq variant. sequenceList runs its tasks concurrently, and a reduce is inherently sequential — element two cannot start until element one finishes — so the concurrent version would change when effects run and in what order.
This one changes behaviour. The reduce stops at the first failure: the tasks after it never run. fpdart’s sequenceListSeq runs every task in order and only then collects the results, so the tasks after a failure still run, and the result is the first failure. For tasks without side effects the two agree; for writes they do not. The lightbulb entry says so — “Convert to ‘sequenceListSeq’ (runs every task after a failure)” — and sits below the exact conversions.
Two smaller differences: reduce throws on an empty iterable, while sequenceListSeq succeeds with an empty list, and the result holds every value (List<R>) instead of the last one.
Convert to chainFirst
Section titled “Convert to chainFirst”Put the cursor on a flatMap((v) => effect(v).map((_) => v)) — the “run an effect, keep the original value” shape.
pipeline.flatMap((user) => audit(user).map((_) => user))becomes
pipeline.chainFirst(audit)Expand to flatMap
Section titled “Expand to flatMap”Put the cursor on an andThen, map or filterOrElse call. The inverse of the three exact narrowings above.
| Before | After |
|---|---|
p.andThen(logout) |
p.flatMap((_) => logout()) |
p.map(transform) |
p.flatMap((value) => TaskEither.of(transform(value))) |
p.filterOrElse((v) => v.isValid, onBad) |
p.flatMap((v) => v.isValid ? TaskEither.of(v) : TaskEither.left(onBad(v))) |
The wrapper is read from what the call returns, not from the receiver, so a map that changes the value type still names the right constructor. .of exists on every fpdart type, so the expansion works the same for Option, Either, Task, IO and their variants.
A closure whose parameter is already the name being bound has its body inlined, so map((raw) => transform(raw)) expands to flatMap((raw) => TaskEither.of(transform(raw))) rather than to an immediately-invoked lambda. A tear-off gets a value parameter, suffixed if that name is already taken in scope.
Why go backwards at all
Section titled “Why go backwards at all”Because the narrow forms hide the previous value, and sometimes you need it again. You write andThen(logout), then the next step turns out to depend on what came before — the first edit is always expanding back to a flatMap whose callback names that value.
The two with no inverse
Section titled “The two with no inverse”chainFirst and sequenceListSeq are deliberately not offered.
Expanding chainFirst honestly means emitting its orElse too:
TaskEither<String, String> expanded(TaskEither<String, String> p) => p.flatMap( (b) => audit(b).map((_) => b).orElse((l) => TaskEither.right(b)), );which nobody wants to read — while the shorter form people expect silently drops the failure-swallowing, which is the very trap the forward assist warns about. Either output is worse than no assist.
sequenceListSeq would expand to a hand-rolled fold needing an empty-list guard that reduce does not supply. That is a downgrade in every case.
Expand tryCatch into try/catch
Section titled “Expand tryCatch into try/catch”Put the cursor on a tryCatch constructor. It handles TaskEither.tryCatch, Either.tryCatch and Option.tryCatch.
Related rule: prefer_task_either_over_try_catch — but only for the TaskEither case below. That rule fires on Future-returning methods, so a synchronous Either or Option is outside its scope and is never reported; those two shapes are covered here on their own.
TaskEither.tryCatch — the try stays inside the lazy constructor, because hoisting it into the enclosing function would run the effect eagerly and defeat the point of the type. The callback’s value is awaited before it is wrapped:
// BeforeTaskEither<Failure, User> fetchUser(String id) => TaskEither.tryCatch( () => getUser(id), (error, stackTrace) => Failure.from(error), );
// AfterTaskEither<Failure, User> fetchUser(String id) => TaskEither(() async { try { return right(await getUser(id)); } catch (error) { return left(Failure.from(error)); } });Note the catch (error): the onError above declares a stackTrace it never reads. An onError may carry an unused parameter, but a catch clause may not — keeping it would hand back code with a fresh unused_catch_stack warning the original could not have had, so it is dropped.
Either.tryCatch — synchronous, so there is no lazy constructor to preserve and the enclosing function simply grows a block:
// BeforeEither<Failure, User> parseUser(String json) => Either.tryCatch( () => User.fromJson(json), (error, stackTrace) => Failure.parse(error, stackTrace), );
// AfterEither<Failure, User> parseUser(String json) { try { return right(User.fromJson(json)); } catch (error, stackTrace) { return left(Failure.parse(error, stackTrace)); }}Here the onError does read its stackTrace, so the parameter survives into the catch.
Option.tryCatch takes no onError, so there is no error to carry — the clause takes nothing and the failure branch is none():
// BeforeOption<User> tryParse(String json) => Option.tryCatch(() => User.fromJson(json));
// AfterOption<User> tryParse(String json) { try { return some(User.fromJson(json)); } catch (_) { return none(); }}tryCatch remains the better form nearly always — it is shorter, cannot forget to wrap a branch, and composes. This assist is for the cases it cannot express: adding logging, retries, or handling per exception type, where a single onError callback is not enough.
Because try is a statement, the assist is offered only when the tryCatch makes up a whole function body (either => ... or { return ...; }). Mid-pipeline — Either.tryCatch(...).flatMap(f) — there is nowhere to put a statement, and the only expression-level equivalent is an immediately-invoked closure, which is worse than what it replaces. A cursor sitting inside an unrelated closure nested in the arguments is declined for the same reason: it is not in the code the assist would rewrite. A tear-off onError such as Failure.from is declined too, since it has no parameter names or body to move into the catch.
Convert null check to pattern
Section titled “Convert null check to pattern”Put the cursor on an if (x != null) guard. Related rule: avoid_non_null_assertion.
// Beforeclass Test { String? field;
void method() { if (field != null) { field!.contains('other'); } }}
// Afterclass Test { String? field;
void method() { if (field case final field_?) { field_.contains('other'); } }}The point is fields. Dart promotes a local or a parameter after x != null, so a ! there is already redundant and the SDK’s own unnecessary_null_checks flags it. A field is never promoted — another method could reassign it between the check and the use — so every access inside the branch needs a ! just to compile. Binding the value to a pattern variable makes it a fresh local, which is promotable, and the bangs go away.
The conversion preserves semantics exactly: the branch is entered on the same values as before, and an else still runs when the value is null. Bare reads of the field inside the branch are rewritten along with the bangs, so the branch never mixes the nullable original with the bound variable.
The generated name is offered as a linked edit, so Tab lands on it for renaming as part of applying the assist.
Two shapes are declined. if (x == null) guards its else branch, and the early-return form (if (x == null) return;) promotes the code after the if — neither is expressible as one case pattern. A method call as the checked expression is declined too, because the pattern re-evaluates its subject and a call could have side effects or return a different value the second time.
Convert null check to destructuring pattern
Section titled “Convert null check to destructuring pattern”Put the cursor on an if (x != null) guard whose branch asserts an inner field with x!.field!.
// Beforeif (userData != null) { sendEvent(userData!.name!);}
// Afterif (userData case UserData(:final name?)) { sendEvent(name);}That is the whole point of the refactor, but it is why this is a separate assist from the one above rather than a smarter version of it. It is offered only when the branch contains x!.field!: asserting the field non-null is the author stating that a null there was never a case they meant to handle, so turning that assertion into a condition matches the intent already written down. Without the inner bang the assist declines, and the semantics-preserving conversion is offered instead.
Two more shapes are declined. When the branch asserts two different fields, there is no single name to destructure and picking one would be arbitrary — apply the assist again after the first conversion. A generic type is declined because Box<String>(:final name?) needs its arguments spelled out, and guessing them produces code that does not compile.
Neither of these ships as a quick fix. Both rewrite the whole if, while avoid_non_null_assertion reports on the ! inside the branch — a fix that restructures the statement above the reported node is hard to predict. And “apply all in file” must not be able to narrow conditions across a codebase in one keystroke.