prefer_task_either_over_try_catch
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.
Why use this rule
Section titled “Why use this rule”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), },);Known limitations
Section titled “Known limitations”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.
Going the other way
Section titled “Going the other way”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<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.
Options
Section titled “Options”many_lints: rules: prefer_task_either_over_try_catch: additional_class_suffixes: - Gateway ignore_private: falserules: 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 |
Configuration
Section titled “Configuration”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:
rules: prefer_task_either_over_try_catch: falseTo keep the rule on but skip certain paths, use per-rule exclude.
Related rules
Section titled “Related rules”avoid_either_of_future— A Future nested in Either or Option escapes the error channel.avoid_future_of_either— Future<Either> throws away the composition TaskEither already gives you.avoid_future_of_option— Future<Option> throws away the composition TaskOption already gives you.avoid_unrun_task— Discarding a lazy fpdart value silently skips the work it describes.