dart_either

Author: Petrus Nguyễn Thái Học

Dart CI pub version pub prerelease codecov License: MIT Style: lints Hits

Either monad for Dart & Flutter — a type-safe, lightweight library for error handling and railway-oriented programming.

  • Monad comprehensions — both sync (Either.binding) and async (Either.futureBinding) versions.
  • Async map / flatMap — hides the boilerplate of working with Future<Either<L, R>>.
  • Type-safe — an explicit, compiler-friendly alternative to nullable values and thrown exceptions.

Credits: Ported and adapted from Λrrow-kt.


Support the project

If you find this library useful, consider buying me a coffee ☕

Buy Me A Coffee

Why dart_either?

Difference from dartz and fpdart

Many projects import entire FP libraries (dartz, fpdart, …) but only use Either. This library extracts and adapts just the Either class from Λrrow-kt, keeping things focused and lightweight.

Feature dart_either
Inspired by Λrrow-kt, Scala Cats
Documentation Fully documented — every method/function has doc comments and examples
Test coverage Fully tested
Completeness Most complete Either implementation available for Dart/Flutter
Monad comprehensions ✅ Both sync and async
Async map / flatMap thenMapEither, thenFlatMapEither
Bundle size Very lightweight and simple (compare to dartz)

Getting started

Add the dependency to your pubspec.yaml:

dependencies:
  dart_either: ^2.1.0

Then run:

dart pub get

Documentation & Examples

Resource Link
📖 API Documentation https://pub.dev/documentation/dart_either/latest/dart_either/
💡 Examples https://github.com/hoc081098/dart_either/tree/master/example/lib
🐦 Flutter Example https://github.com/hoc081098/node-auth-flutter-BLoC-pattern-RxDart

Either monad

Either<L, R> represents one of two possible values:

  • Right(R) — the "desired" / success value (right-biased).
  • Left(L) — the "undesired" / error value.

Related implementations in other languages:

Why Either? (click to expand)

In day-to-day programming, it is fairly common to find ourselves writing functions that can fail. For instance, querying a service may result in a connection issue, or some unexpected JSON response.

To communicate these errors, it has become common practice to throw exceptions; however, exceptions are not tracked in any way, shape, or form by the compiler. To see what kind of exceptions (if any) a function may throw, we have to dig through the source code. Then, to handle these exceptions, we have to make sure we catch them at the call site. This all becomes even more unwieldy when we try to compose exception-throwing procedures.

// What exceptions can this throw? You have to dig through the source to find out.
double throwsSomeStuff(int i) => throw UnimplementedError();

// Same here — no way to know from the type signature alone.
String throwsOtherThings(double d) => throw UnimplementedError();

// And here too.
List<int> moreThrowing(String s) => throw UnimplementedError();

// Any of the three above can throw — good luck tracking which one failed!
List<int> magic(int i) => moreThrowing( throwsOtherThings( throwsSomeStuff(i) ) );

Assume we happily throw exceptions in our code. Looking at the types of the functions above, any could throw a number of exceptions — we do not know. When we compose, exceptions from any of the constituent functions can be thrown. Moreover, they may throw the same kind of exception (e.g., ArgumentError) and, thus, it gets tricky tracking exactly where an exception came from.

How then do we communicate an error? By making it explicit in the data type we return.

Either is used to short-circuit a computation upon the first error. By convention, the right side of an Either is used to hold successful values.

Because Either is right-biased, it is possible to define a Monad instance for it. Since we only ever want the computation to continue in the case of Right (as captured by the right-bias nature), we fix the left type parameter and leave the right one free. So, the map and flatMap methods are right-biased.

Example:

/// Create an instance of [Right]
final right = Either<String, int>.right(10); // Either.Right(10)

/// Create an instance of [Left]
final left = Either<String, int>.left('none'); // Either.Left(none)

/// Map the right value to a [String]
final mapRight = right.map((a) => 'String: $a'); // Either.Right(String: 10)

/// Map the left value to a [int]
final mapLeft = right.mapLeft((a) => a.length); // Either.Right(10)

/// Return [Left] if the function throws an error, otherwise return [Right]
final catchError = Either.catchError(
  (e, s) => 'Error: $e',
  () => int.parse('invalid'),
);
// Either.Left(Error: FormatException: Invalid radix-10 number (at character 1)
// invalid
// ^
// )

/// Extract the value from [Either]
final value1 = right.getOrElse(() => -1); // 10
final value2 = right.getOrHandle((l) => -1); // 10

/// Chain computations
final flatMap = right.flatMap((a) => Either.right(a + 10)); // Either.Right(20)

/// Pattern matching
right.fold(
  ifLeft: (l) => print('Left value: $l'),
  ifRight: (r) => print('Right value: $r'),
); // Right value: 10

right.when(
  ifLeft: (l) => print('Left: $l'),
  ifRight: (r) => print('Right: $r'),
); // Prints Right: Either.Right(10)

// Or use Dart 3.0 switch expression syntax 🤘
print(
  switch (right) {
    Left() => 'Left: $right',
    Right() => 'Right: $right',
  },
); // Prints Right: Either.Right(10)

/// Convert to nullable value
final nullableValue = right.orNull(); // 10

API Reference

Full API docs: https://pub.dev/documentation/dart_either/latest/dart_either/

1. Creation

1.1. Factory constructors

Constructor Description
Either.left Creates a Left value
Either.right Creates a Right value
Either.binding Sync monad comprehension
Either.catchError Wraps a throwing expression
Left Direct Left constructor
Right Direct Right constructor
// Left('Left value')
final left = Either<Object, String>.left('Left value');
// or: Left<Object, String>('Left value')

// Right(1)
final right = Either<Object, int>.right(1);
// or: Right<Object, int>(1)

// Left('Left value') — short-circuits on the first bind that returns Left
Either<Object, String>.binding((e) {
  final String s = left.bind(e);
  final int i = right.bind(e);
  return '$s $i';
});

// Left(FormatException(...))
Either.catchError(
  (e, s) => 'Error: $e',
  () => int.parse('invalid'),
);

1.2. Static methods

Method Description
Either.catchFutureError Wraps an async throwing expression
Either.catchStreamError Wraps a stream that may throw
Either.fromNullable Converts a nullable value
Either.futureBinding Async monad comprehension
Either.parSequenceN Parallel sequence with concurrency limit
Either.parTraverseN Parallel traverse with concurrency limit
Either.sequence Sequences a list of Eithers
Either.traverse Maps + sequences a list
import 'package:http/http.dart' as http;

// ─── Either.catchFutureError ─────────────────────────────────────────────────
Future<Either<String, http.Response>> eitherFuture = Either.catchFutureError(
  (e, s) => 'Error: $e',
  () async {
    final uri = Uri.parse('https://pub.dev/packages/dart_either');
    return http.get(uri);
  },
);
(await eitherFuture).fold(ifLeft: print, ifRight: print);


// ─── Either.catchStreamError ─────────────────────────────────────────────────
Stream<int> genStream() async* {
  for (var i = 0; i < 5; i++) {
    yield i;
  }
  throw Exception('Fatal');
}
Stream<Either<String, int>> eitherStream = Either.catchStreamError(
  (e, s) => 'Error: $e',
  genStream(),
);
eitherStream.listen(print);


// ─── Either.fromNullable ─────────────────────────────────────────────────────
Either.fromNullable<int>(null); // Left(null)
Either.fromNullable<int>(1);    // Right(1)


// ─── Either.futureBinding ────────────────────────────────────────────────────
String url1 = 'url1';
String url2 = 'url2';
Either.futureBinding<String, http.Response>((e) async {
  final response = await Either.catchFutureError(
    (e, s) => 'Get $url1: $e',
    () async {
      final uri = Uri.parse(url1);
      return http.get(uri);
    },
  ).bind(e);

  final id = Either.catchError(
    (e, s) => 'Parse $url1 body: $e',
    () => jsonDecode(response.body)['id'] as String,
  ).bind(e);

  return await Either.catchFutureError(
    (e, s) => 'Get $url2: $e',
    () async {
      final uri = Uri.parse('$url2?id=$id');
      return http.get(uri);
    },
  ).bind(e);
});


// ─── Either.sequence ─────────────────────────────────────────────────────────
List<Either<String, http.Response>> eithers = await Future.wait(
  [1, 2, 3, 4, 5].map((id) {
    final url = 'url?id=$id';
    return Either.catchFutureError(
      (e, s) => 'Get $url: $e',
      () async {
        final uri = Uri.parse(url);
        return http.get(uri);
      },
    );
  }),
);
Either<String, BuiltList<http.Response>> result = Either.sequence(eithers);


// ─── Either.traverse ─────────────────────────────────────────────────────────
Either<String, BuiltList<Uri>> urisEither = Either.traverse(
  ['url1', 'url2', '::invalid::'],
  (String uriString) => Either.catchError(
    (e, s) => 'Failed to parse $uriString: $e',
    () => Uri.parse(uriString),
  ),
); // Left(FormatException('Failed to parse ::invalid:::...'))


// ─── Either.parSequenceN ─────────────────────────────────────────────────────
Future<Either<String, BuiltList<int>>> result = Either.parSequenceN(
  functions: [
    () async => fetchNumber(1),
    () async => fetchNumber(2),
    () async => fetchNumber(3),
  ],
  maxConcurrent: 2,
);

// ─── Either.parTraverseN ─────────────────────────────────────────────────────
Future<Either<String, BuiltList<int>>> idsEither = Either.parTraverseN(
  values: [1, 2, 3],
  mapper: (id) => () async => fetchNumber(id),
  maxConcurrent: 2,
);

1.3. Extension methods

Extension Description
Stream.toEitherStream Converts a stream, catching errors into Left
Future.toEitherFuture Converts a future, catching errors into Left
T.left Wraps any value as Left
T.right Wraps any value as Right
// ─── Stream.toEitherStream ───────────────────────────────────────────────────
Stream<int> genStream() async* {
  for (var i = 0; i < 5; i++) {
    yield i;
  }
  throw Exception('Fatal');
}
Stream<Either<String, int>> eitherStream =
    genStream().toEitherStream((e, s) => 'Error: $e');
eitherStream.listen(print);


// ─── Future.toEitherFuture ───────────────────────────────────────────────────
Future<Either<Object, int>> f1 =
    Future<int>.error('An error').toEitherFuture((e, s) => e);
Future<Either<Object, int>> f2 =
    Future<int>.value(1).toEitherFuture((e, s) => e);
await f1; // Left('An error')
await f2; // Right(1)


// ─── T.left / T.right ────────────────────────────────────────────────────────
Either<int, String> left = 1.left<String>();
Either<String, int> right = 2.right<String>();

2. Operations

Method Description
isLeft Returns true if this is a Left
isRight Returns true if this is a Right
fold Applies one of two functions based on variant
foldLeft Left fold with an initial value
swap Swaps Left and Right
tapLeft Side-effect on Left
tap Side-effect on Right
map Transforms the Right value
mapLeft Transforms the Left value
flatMap Chains computations
bimap Transforms both sides
exists Tests the Right value with a predicate
all Returns true for Left or if Right matches the predicate
getOrElse Extracts Right or falls back to a default
orNull Extracts Right or returns null
getOrHandle Extracts Right or maps Left to a value
findOrNull Finds Right matching a predicate
when Pattern-match returning the matched value
handleErrorWith Recovers from Left with a new Either
handleError Recovers from Left with a new Right value
redeem Maps both sides to the same type
redeemWith Maps both sides to a new Either
toFuture Converts to a Future
getOrThrow Extracts Right or throws the Left value

3. Extensions on Future<Either<L, R>>

Method Description
thenFlatMapEither Async flatMap on a Future<Either>
thenMapEither Async map on a Future<Either>
Future<Either<AsyncError, dynamic>> httpGetAsEither(String uriString) {
  Either<AsyncError, dynamic> toJson(http.Response response) =>
      response.statusCode >= 200 && response.statusCode < 300
          ? Either<AsyncError, dynamic>.catchError(
              toAsyncError,
              () => jsonDecode(response.body),
            )
          : AsyncError(
              HttpException(
                'statusCode=${response.statusCode}, body=${response.body}',
                uri: response.request?.url,
              ),
              StackTrace.current,
            ).left<dynamic>();

  Future<Either<AsyncError, http.Response>> httpGet(Uri uri) =>
      Either.catchFutureError(toAsyncError, () => http.get(uri));

  final uri =
      Future.value(Either.catchError(toAsyncError, () => Uri.parse(uriString)));

  return uri.thenFlatMapEither(httpGet).thenFlatMapEither<dynamic>(toJson);
}

Either<AsyncError, BuiltList<User>> toUsers(List list) { ... }

Either<AsyncError, BuiltList<User>> result = await httpGetAsEither(
        'https://jsonplaceholder.typicode.com/users')
    .thenMapEither((dynamic json) => json as List)
    .thenFlatMapEither(toUsers);

4. Monad comprehensions

Use Either.binding (sync) or Either.futureBinding (async) for do-notation style sequential computations that short-circuit on the first Left.

Future<Either<AsyncError, dynamic>> httpGetAsEither(String uriString) =>
    Either.futureBinding<AsyncError, dynamic>((e) async {
      final uri =
          Either.catchError(toAsyncError, () => Uri.parse(uriString)).bind(e);

      final response = await Either.catchFutureError(
        toAsyncError,
        () => http.get(uri),
      ).bind(e);

      e.ensure(
        response.statusCode >= 200 && response.statusCode < 300,
        () => AsyncError(
          HttpException(
            'statusCode=${response.statusCode}, body=${response.body}',
            uri: response.request?.url,
          ),
          StackTrace.current,
        ),
      );

      return Either<AsyncError, dynamic>.catchError(
        toAsyncError,
        () => jsonDecode(response.body),
      ).bind(e);
    });

Either<AsyncError, BuiltList<User>> toUsers(List list) { ... }

Either<AsyncError, BuiltList<User>> result = await Either.futureBinding((e) async {
  final dynamic json =
      await httpGetAsEither('https://jsonplaceholder.typicode.com/users').bind(e);
  final BuiltList<User> users = toUsers(json as List).bind(e);
  return users;
});

References


Features and bugs

Please file feature requests and bugs at the issue tracker.


License

MIT License

Copyright (c) 2021-2026 Petrus Nguyễn Thái Học