avoid_ad_hoc_left_type
Flags an Either, TaskEither or IOEither whose error channel carries a type outside the failure hierarchy you configure.
flatMap only composes when every step shares one left type. A step that returns TaskEither<String, T> does not chain onto one returning TaskEither<Failure, T> — it has to be bridged with mapLeft at the junction, and the usual bridge is (e) => e.toString(). That is where the damage is done: once the left side is a String, the fold at the boundary can no longer switch on what went wrong.
This rule is in no preset and reports nothing until you set error_types:. Every example below shows the configuration that produces it.
many_lints: rules: avoid_ad_hoc_left_type: error_types: - Failure allow_subtypes: truerules: avoid_ad_hoc_left_type: error_types: - Failure allow_subtypes: trueExamples
Section titled “Examples”One odd step breaks the chain
Section titled “One odd step breaks the chain”rules: avoid_ad_hoc_left_type: error_types: - Failureimport 'package:fpdart/fpdart.dart';
sealed class Failure {}
class NetworkFailure extends Failure {}
class ParseFailure extends Failure {}
class User { const User(this.id);
final String id;}
// Don't — `fetch` reports a String, so `loadUser` cannot chain onto `parse`// without flattening `ParseFailure` into a message first.TaskEither<String, String> fetch(String id) => // LINT on `String` TaskEither.right('{"id":"$id"}');
TaskEither<Failure, User> parse(String body) => TaskEither.right(User(body));
TaskEither<String, User> loadUser(String id) => // LINT on `String` fetch(id).flatMap((body) => parse(body).mapLeft((f) => f.toString()));import 'package:fpdart/fpdart.dart';
sealed class Failure {}
class NetworkFailure extends Failure {}
class ParseFailure extends Failure {}
class User { const User(this.id);
final String id;}
// Do — one left type end to end, so flatMap composes with no bridge.TaskEither<Failure, String> fetch(String id) => TaskEither.right('{"id":"$id"}');
TaskEither<Failure, User> parse(String body) => TaskEither.right(User(body));
TaskEither<Failure, User> loadUser(String id) => fetch(id).flatMap(parse);The fold stays exhaustive
Section titled “The fold stays exhaustive”Keeping the hierarchy in the error channel is what lets the boundary branch on the failure instead of printing it:
import 'package:fpdart/fpdart.dart';
sealed class Failure {}
class NetworkFailure extends Failure {}
class AuthFailure extends Failure {}
class User { const User(this.id);
final String id;}
String describe(Either<Failure, User> result) => result.match( (failure) => switch (failure) { NetworkFailure() => 'Offline — retrying.', AuthFailure() => 'Session expired — sign in again.', }, (user) => 'Signed in as ${user.id}',);Had fetch flattened its failure to a String, that switch would have been a string comparison.
Subtypes are accepted by default
Section titled “Subtypes are accepted by default”allow_subtypes: true — the default — means naming the sealed root covers the whole hierarchy:
rules: avoid_ad_hoc_left_type: error_types: - Failureimport 'package:fpdart/fpdart.dart';
sealed class Failure {}
class NetworkFailure extends Failure {}
// Accepted — NetworkFailure is a FailureTaskEither<NetworkFailure, int> ping() => TaskEither.right(1);Set it to false when the point is that every signature spells the same name:
rules: avoid_ad_hoc_left_type: error_types: - Failure allow_subtypes: falseimport 'package:fpdart/fpdart.dart';
sealed class Failure {}
class NetworkFailure extends Failure {}
// Don't — exact match requiredTaskEither<NetworkFailure, int> ping() => // LINT TaskEither.right(1);
// DoTaskEither<Failure, int> pingChecked() => TaskEither.right(1);Naming more than one allowed type
Section titled “Naming more than one allowed type”A project that keeps domain failures separate from transport ones lists both roots:
rules: avoid_ad_hoc_left_type: error_types: - Failure - HttpErrorimport 'package:fpdart/fpdart.dart';
sealed class Failure {}
sealed class HttpError {}
// Both acceptedTaskEither<Failure, int> readSetting() => TaskEither.right(1);
IOEither<HttpError, int> statusCode() => IOEither.right(200);
// Don't — Exception is neitherTaskEither<Exception, int> refresh() => // LINT TaskEither.right(1);Options
Section titled “Options”| Option | Type | Default | Description |
|---|---|---|---|
error_types |
list of strings | (none) | The type names allowed in the error channel. Required — the rule is silent without it |
allow_subtypes |
bool | true |
Accept a subtype of a named type, so a sealed hierarchy works by naming only its root |
Known limitations
Section titled “Known limitations”Only the two-parameter wrappers are checked. Either, TaskEither and IOEither have an error channel; Option, Task and IO do not and are never reported.
Types are matched by name, not by package. A project’s own failure hierarchy is declared in the analysed package and has no package: URI to pin against, so an unrelated class of the same name from a dependency would also be accepted.
No quick fix. Replacing the type means deciding which failure this step produces, which is the design work the rule is asking for.
See also: fpdart: Either
Turning this rule off
Section titled “Turning this rule off”To disable this rule:
rules: avoid_ad_hoc_left_type: falseTo keep the rule on but skip certain paths, use per-rule exclude.
Related rules
Section titled “Related rules”avoid_bare_await_in_do— Awaiting a raw Future inside a Do block escapes the block’s tracking.avoid_chain_first_swallowing_failure— chainFirst turns a failing effect into success.avoid_dollar_outside_do_frame— Calling a Do block’s extraction function from a nested callback unwinds through code that cannot handle it.avoid_either_of_future— A Future nested in Either or Option escapes the error channel.