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

The signature says Future<User>, which promises there is nothing to handle. The caller finds out otherwise at runtime:

class Failure {
const Failure(this.message);
final String message;
}
class User {
const User(this.id);
final String id;
}
class UserLoadException implements Exception {
const UserLoadException(this.cause);
final Object cause;
}
class Api {
Future<User> getUser(String id) async => User(id);
}
class UserRepository {
UserRepository(this._api);
final Api _api;
Future<User> load(String id) async {
try {
return await _api.getUser(id);
} catch (e) {
throw UserLoadException(e); // the caller still cannot see this coming
}
}
}

Put the failure in the type, so the compiler enforces what the docstring used to ask for:

class UserRepository {
UserRepository(this._api);
final Api _api;
TaskEither<Failure, User> load(String id) => TaskEither.tryCatch(
() => _api.getUser(id),
(error, stackTrace) => Failure('$error'),
);
}

Discriminating known exceptions belongs in the error mapper:

class AuthException implements Exception {
const AuthException(this.message);
final String message;
}
TaskEither<Failure, User> signIn(Api api, String id) => TaskEither.tryCatch(
() => api.getUser(id),
(error, stackTrace) => switch (error) {
AuthException(:final message) => Failure(message),
_ => Failure('$error'),
},
);

Private methods are skipped by default — they are implementation details, not the contract callers see:

class UserRepository {
UserRepository(this._api);
final Api _api;
Future<User?> _tryLoad(String id) async {
try {
return await _api.getUser(id);
} catch (e) {
return null; // not reported
}
}
}

With ignore_private: false:

many_lints.yaml
rules:
prefer_task_either_over_try_catch:
ignore_private: false

those are reported too.

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.

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.

This rule asks you to replace a hand-written try/catch with TaskEither.tryCatch. Once you have, the “Expand tryCatch into try/catch” assist takes you back — put the cursor on the tryCatch and pick it from the lightbulb menu.

Reach for it when a single onError callback cannot express what you need: logging, retries, or handling per exception type.

// Before — what this rule asked you to write.
TaskEither<Failure, User> fetchUser(Api api, String id) => TaskEither.tryCatch(
() => api.getUser(id),
(error, stackTrace) => Failure('$error'),
);
// After — the try stays inside the lazy constructor, because hoisting it into
// the enclosing function would run the effect eagerly.
TaskEither<Failure, User> fetchUser(Api api, String id) =>
TaskEither(() async {
try {
return right(await api.getUser(id));
} catch (error) {
return left(Failure('$error'));
}
});

The assist also handles Either.tryCatch and Option.tryCatch. Note that neither is ever reported by this rule — it only fires on Future-returning methods, so a synchronous Either or Option is outside its scope. The two are listed on the assists page with their own before/after examples.

The assist is offered only when the tryCatch is a whole function body — try is a statement, so mid-pipeline (Either.tryCatch(...).flatMap(f)) there is nowhere to put one. A tear-off onError (Failure.from) is declined too, having 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, since catch may not carry an unused parameter.

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.