dart_either

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

Dart CI Pub Pub codecov GitHub Style Hits

Either monad for Dart language and Flutter framework. The library for error handling and railway oriented programming. Supports Monad comprehensions (both sync and async versions). Supports async map and async flatMap hiding the boilerplate of working with asynchronous computations Future<Either<L, R>>. Error handler library for type-safe and easy work with errors on Dart and Flutter. Either is an alternative to Nullable value and Exceptions.

Credits: port and adapt from Λrrow-kt.

Liked some of my work? Buy me a coffee (or more likely a beer)

Buy Me A Coffee

Difference from other implementations (dartz and fpdart)

I have seen a lot of people importing whole libraries such as dartz and fpdart, ... but they only use Either class :). So I decided to write, port and adapt Either class from Λrrow-kt.

  • Inspired by Λrrow-kt, Scala Cats.
  • Fully documented, tested and many examples. Every method/function in this library is documented with examples.
  • This library is most complete Either implementation, which supports Monad comprehensions (both sync and async versions), and supports async map and async flatMap hiding the boilerplate of working with asynchronous computations Future<Either<L, R>>.
  • Very lightweight and simple library (compare to dartz).

Getting started

In your Dart/Flutter project, add the dependency to your pubspec.yaml

dependencies:
  dart_either: ^1.0.0

Documentation & example

Either monad

Either is a type that represents either Right (usually represent a "desired" value) or Left (usually represent a "undesired" value or error value).

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.

double throwsSomeStuff(int i) => throw UnimplementedError();
///
String throwsOtherThings(double d) => throw UnimplementedError();
///
List<int> moreThrowing(String s) => throw UnimplementedError();
///
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: $l'),
  ifRight: (r) => print('Right: $r'),
); // Right: 10

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

Use - Documentation

1. Creation

1.1. Factory constructors

// 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')
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

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:::...'))

1.3. Extension methods

/// 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

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

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

You can use Monad comprehensions via Either.binding and Either.futureBinding.

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;
});

Reference

Features and bugs

Please file feature requests and bugs at the issue tracker.

License

MIT License

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