Skip to content

prefer_task_either_over_try_catch

v1.0.0WarningConfigurablefpdart

This rule flags an async method on a boundary class — one whose name ends in Repository, Service, DataSource or Client — that handles failure with try/catch instead of returning a TaskEither.

A repository’s failures are part of its contract, not exceptions. Callers have to handle “the network was down” every single time, and a signature that says Future<User> promises the opposite.

The try/catch inside then has nowhere good to go. It either swallows the error and returns a fallback the caller cannot distinguish from success, or rethrows and leaves the caller exactly where it started — needing to know, from documentation alone, which exceptions to expect.

TaskEither<Failure, T> puts the failure in the type, so the compiler enforces what the docstring used to ask for.

Only boundary classes are checked. This is a statement about architecture, not about try/catch in general — inside a widget or a utility, catching an exception is often exactly right.

See also: fpdart: TaskEither

class UserRepository {
Future<User> load(String id) async {
try {
return await _api.getUser(id);
} catch (e) {
throw UserLoadException(e); // the caller still cannot see this coming
}
}
}
class UserRepository {
TaskEither<Failure, User> load(String id) => TaskEither.tryCatch(
() => _api.getUser(id),
(error, stackTrace) => Failure.from(error),
);
}

Discriminating known exceptions belongs in the error mapper:

TaskEither.tryCatch(
() => authClient.signIn(email, password),
(error, stackTrace) => switch (error) {
AuthException(:final message) => AuthFailure(message),
_ => Failure.from(error),
},
);

Only async methods are reported. A synchronous failable method is Either’s job, and suggesting TaskEither there would be wrong.

A try/finally with no catch is cleanup, not failure handling, and is never reported.

A try inside a closure belongs to that closure’s own control flow — often a genuinely best-effort adapter — and is not what the method’s signature promises, so it does not count.

Private methods are skipped by default: they are implementation details of the class rather than part of the contract callers see. Set ignore_private: false to report them too.

No quick fix is offered. Converting means choosing the failure type, writing the error mapper, and updating every call site — a change to make deliberately.

There is an assist for the reverse direction: put the cursor on a tryCatch constructor and pick “Expand tryCatch into try/catch. It handles Either.tryCatch, TaskEither.tryCatch and Option.tryCatch.

Either.tryCatch
Either<Failure, User> parseUser(String json) {
try {
return right(User.fromJson(json));
} catch (error, stackTrace) {
return left(Failure.parse(error, stackTrace));
}
}
// TaskEither.tryCatch — the try stays inside the lazy constructor, because
// hoisting it into the enclosing function would run the effect eagerly.
TaskEither<Failure, User> fetchUser(String id) => TaskEither(() async {
try {
return right(await api.getUser(id));
} catch (error) {
return left(Failure.from(error));
}
});
// Option.tryCatch — no onError, so nothing to carry.
Option<User> tryParse(String json) {
try {
return some(User.fromJson(json));
} catch (_) {
return none();
}
}

This rule’s preference still stands: tryCatch is shorter, cannot forget to wrap a branch, and composes. The assist is for the cases it cannot express — adding logging, retries, or handling per exception type, where the single onError callback is not enough.

Because try is a statement, the assist is offered only when the tryCatch is a whole function body. 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; those are declined. A tear-off onError (Failure.from) is declined too, as it has no parameter names or body to move into the catch.

A stack-trace parameter that onError declares but never reads is dropped from the generated clause. onError may carry an unused parameter, but catch may not — keeping it would hand back code with a fresh unused_catch_stack warning that the original could not have had.

analysis_options.yaml
many_lints:
rules:
prefer_task_either_over_try_catch:
additional_class_suffixes:
- Gateway
ignore_private: false
Option Type Default Description
class_suffixes list of strings Repository, Service, DataSource, Client Replace the set of class name suffixes that mark a boundary
additional_class_suffixes list of strings [] Extend the set instead of replacing it
ignore_private bool true Skip private methods, which are implementation details rather than contract

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

To turn it off:

many_lints.yaml
rules:
prefer_task_either_over_try_catch: false

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