Skip to content

avoid_ad_hoc_left_type

v1.0.0WarningConfigurablefpdart

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.

analysis_options.yaml
many_lints:
rules:
avoid_ad_hoc_left_type:
error_types:
- Failure
allow_subtypes: true
rules:
avoid_ad_hoc_left_type:
error_types:
- Failure
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;
}
// 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);

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.

allow_subtypes: true — the default — means naming the sealed root covers the whole hierarchy:

rules:
avoid_ad_hoc_left_type:
error_types:
- Failure
import 'package:fpdart/fpdart.dart';
sealed class Failure {}
class NetworkFailure extends Failure {}
// Accepted — NetworkFailure is a Failure
TaskEither<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: false
import 'package:fpdart/fpdart.dart';
sealed class Failure {}
class NetworkFailure extends Failure {}
// Don't — exact match required
TaskEither<NetworkFailure, int> ping() => // LINT
TaskEither.right(1);
// Do
TaskEither<Failure, int> pingChecked() => TaskEither.right(1);

A project that keeps domain failures separate from transport ones lists both roots:

rules:
avoid_ad_hoc_left_type:
error_types:
- Failure
- HttpError
import 'package:fpdart/fpdart.dart';
sealed class Failure {}
sealed class HttpError {}
// Both accepted
TaskEither<Failure, int> readSetting() => TaskEither.right(1);
IOEither<HttpError, int> statusCode() => IOEither.right(200);
// Don't — Exception is neither
TaskEither<Exception, int> refresh() => // LINT
TaskEither.right(1);
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

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

To disable this rule:

many_lints.yaml
rules:
avoid_ad_hoc_left_type: false

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