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.bindingAsync) 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.3.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:

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

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

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

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

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

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

// Chain computations
final Either<String, int> flatMap = right.flatMap((a) => Either.right(a + 10)); // Either.Right(20)
final Either<String, int> combined = right.combine(
  Either<String, int>.right(5),
  combineLeft: (a, b) => '$a,$b',
  combineRight: (a, b) => a + b,
); // Either.Right(15)
final Either<String, int> flattened = Either<String, Either<String, int>>.right(
  Either<String, int>.right(10),
).flatten(); // Either.Right(10)
final int merged = Either<int, int>.right(10).merge(); // 10

// 3) Pattern matching
// 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 int? nullableValue = right.getOrNull(); // 10
final String? leftValue = left.leftOrNull(); // 'none'
print(leftValue); // 'none'
print(nullableValue); // 10

API Reference

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

1. Creating Either values

Constructor Description
Either.left Creates a Left value
Either.right Creates a Right value
Either.fromNullable Converts a nullable value
Left Direct Left constructor
Right Direct Right constructor
T.left Wraps any value as Left
T.right Wraps any value as Right
// 1) Create Left/Right
final Either<Object, String> left = Either.left('Left value');
// or: Left<Object, String>('Left value')

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

// 2) Convert a nullable value
Either.fromNullable<int>(null); // Either.Left(null)
Either.fromNullable<int>(1);    // Either.Right(1)

// 3) Receiver-style constructors
final Either<int, String> receiverLeft = 1.left<String>(); // Either.Left(1)
final Either<String, int> receiverRight = 1.right<String>(); // Either.Right(1)

2. Error capture

API Description
Either.tryCatch Captures errors thrown by a synchronous action
Either.tryCatchAsync Captures sync and async errors from an action
Future.toEitherFuture Converts an existing future's outcome
Stream.toEitherStream Converts an existing stream's events
Either.registerFatalError Excludes an error type from capture

Use tryCatch for synchronous actions and tryCatchAsync when invoking an asynchronous action may fail either before or after it returns a future:

final Either<String, int> parsed = Either.tryCatch(
  action: () => int.parse('invalid'),
  errorMapper: (error, stackTrace) => 'Error: $error',
); // Either.Left(Error: FormatException: ...)

final Either<String, int> loaded = await Either.tryCatchAsync(
  action: () async => int.parse('42'),
  errorMapper: (error, stackTrace) => 'Error: $error',
); // Either.Right(42)

Use the receiver extensions when the Future or Stream has already been created:

String mapError(Object error, StackTrace stackTrace) => 'Error: $error';

final Either<String, int> futureRight = await Future<int>.value(1).toEitherFuture(mapError);
final Either<String, int> futureLeft = await Future<int>.error(Exception('boom')).toEitherFuture(mapError);

print(futureRight); // Either.Right(1)
print(futureLeft);  // Either.Left(Error: Exception: boom)

final Stream<Either<String, int>> valueStream = Stream<int>.fromIterable([1, 2]).toEitherStream(mapError);
final Stream<Either<String, int>> errorStream = Stream<int>.error(Exception('boom')).toEitherStream(mapError);

print(await valueStream.toList()); // [Either.Right(1), Either.Right(2)]
print(await errorStream.toList()); // [Either.Left(Error: Exception: boom)]

Register application-specific errors that must not be converted to Left:

class CancellationException implements Exception {}

void configureErrorCapture() {
  Either.registerFatalError<CancellationException>();
}

registerFatalError<T>() keeps a separate registry in each Dart isolate. Registering a type in one isolate does not affect other isolates, so each spawned isolate must register the types it needs. Registrations are additive, and registering the same type more than once has no additional effect.

The policy applies to tryCatch, tryCatchAsync, Future.toEitherFuture, and Stream.toEitherStream. An error matching a registered type, including any subtype of it, remains an error instead of being converted to Left.

Both Right and Left are ordinary values in the returned Either. A matching fatal error stays in Dart's error channel instead: tryCatch rethrows it, the Future-based APIs complete with it as an error, and toEitherStream forwards it as a stream error event. Internal binding-control signals are excluded from capture automatically.

Migrating from deprecated error-capture APIs

The old names remain available in 2.x so existing code keeps working:

  • Either.catchError is deprecated in favor of Either.tryCatch.
  • Either.catchFutureError is deprecated in favor of Either.tryCatchAsync.
  • Either.catchStreamError is deprecated in favor of Stream.toEitherStream.

3. Collection operations

API Description
Either.sequence Sequences multiple Either values
Either.traverse Maps values and sequences the results
Either.parSequenceN Sequences async actions with concurrency control
Either.parTraverseN Maps and runs async actions with concurrency control
import 'package:built_collection/built_collection.dart';

final Either<String, BuiltList<int>> sequenced = Either.sequence([Either.right(1), Either.right(2)]);

final Either<String, BuiltList<int>> traversed = Either.traverse(
  ['1', 'invalid'],
  (text) => Either.tryCatch(
    action: () => int.parse(text),
    errorMapper: (error, stackTrace) => 'Invalid integer: $text',
  ),
);

Future<Either<String, int>> fetchNumber(int value) async => Either.right(value);

final Either<String, BuiltList<int>> parallelSequence = await Either.parSequenceN(
  functions: [
    () => fetchNumber(1),
    () => fetchNumber(2),
  ],
  maxConcurrent: 2,
);

final Either<String, BuiltList<int>> parallelTraverse = await Either.parTraverseN(
  values: [1, 2],
  mapper: (value) => () => fetchNumber(value),
  maxConcurrent: 2,
);

4. Operations on Either

Inspecting and folding

Method Description
isLeft Returns true if this is a Left
isRight Returns true if this is a Right
isLeftAnd Tests the Left value with a predicate
isRightAnd Tests the Right value with a predicate
all Returns true for Left or a matching Right
findOrNull Finds a matching Right value
fold Applies one of two functions based on variant
foldLeft Left fold with an initial value
when Pattern-matches and returns the matched value

Transforming and composing

Method Description
map Transforms the Right value
mapLeft Transforms the Left value
flatMap Chains computations
bimap Transforms both sides
swap Swaps Left and Right
combine Combines two Either values
flatten Flattens nested Either
merge Extracts a value when both sides have the same type

Recovering and extracting

Method Description
getOrDefault Extracts Right or falls back to an eager default value
getOrNull Extracts Right or returns null
leftOrNull Extracts Left or returns null
getOrHandle Extracts Right or maps Left to a value
handleErrorWith Lets Left choose a new Either channel
handleError Recovers Left into Right
redeem Maps either side into a new Right value
redeemWith Lets either side choose a new Either channel
getOrThrow Extracts Right or throws the Left value

The recovery family differs by which input channel it handles and whether the selected callback may choose the output channel:

Operation Left input Right input
handleErrorWith Callback returns Left or Right Value stays in Right; callback is skipped
handleError Callback value is wrapped in Right Original Right is returned; callback is skipped
redeem Callback value is wrapped in Right Callback value is wrapped in Right
redeemWith Callback returns Left or Right Callback returns Left or Right

Each operation invokes at most one callback. Exceptions thrown by the selected callback propagate unchanged. redeem always produces a runtime Right on normal completion, while its declared return type remains Either<L, R2>.

Side effects and conversion

Method Description
onLeft Side effect on Left
onRight Side effect on Right
toFuture Converts to a Future
final Either<String, int> ok = Either.right(10);
final Either<String, int> err = Either.left('boom');

// Predicates
ok.isRightAnd((v) => v > 0); // true
err.isLeftAnd((v) => v == 'boom'); // true
err.all((_) => false); // true

// Side effects
ok.onRight(print); // prints 10
err.onLeft(print); // prints boom

// Transformations and composition
ok.map((v) => v + 1); // Right(11)
ok.combine(
  Either<String, int>.right(2),
  combineLeft: (a, b) => '$a,$b',
  combineRight: (a, b) => a + b,
); // Right(12)
Either<String, Either<String, int>>.right(ok).flatten(); // Right(10)

// Recovery
err.handleError((l) => l.length); // Right(4)
err.handleErrorWith((l) => Either<String, int>.right(l.length)); // Right(4)
err.redeem(
  leftOperation: (l) => l.length,
  rightOperation: (r) => r * 2,
); // Right(4)
ok.redeemWith(
  leftOperation: (l) => Right<bool, String>(l),
  rightOperation: (r) => Left<bool, String>(r.isEven),
); // Left(true)

// Extractions
ok.getOrDefault(0); // 10
err.getOrHandle((l) => l.length); // 4
ok.getOrNull(); // 10
err.leftOrNull(); // 'boom'
Either<int, int>.right(10).merge(); // 10

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

Migrating from deprecated operation names

  • tapLeft is deprecated in favor of onLeft.
  • tap is deprecated in favor of onRight.
  • orNull is deprecated in favor of getOrNull.
  • exists is deprecated in favor of isRightAnd.
  • getOrElse is deprecated. Use getOrDefault(value) for an eager fallback, or getOrHandle((left) => value) for a lazy, left-aware fallback.

5. Monad comprehensions

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

Their callback receives an EitherEffect<L>: a package-issued, opaque, scope-bound binding capability. Use it as effect.bind(either), either.bind(effect), eitherFuture.bind(effect), or effect.raise(value). Its construction and binding behavior are library-owned; assigning it to another variable only aliases the same scope. Each Either.binding or Either.bindingAsync invocation owns an isolated scope, ordinary exceptions propagate unchanged, and the capability must not be stored or invoked after that scope settles.

Running a binding scope

API Description
Either.binding Runs a synchronous binding scope
Either.bindingAsync Runs an asynchronous binding scope

Extracting bound values

These four forms have the same short-circuit semantics; choose the syntax that best matches the value already in hand.

API Description
EitherEffect.bind Extracts an Either through the capability
Either.bind Extracts itself through an EitherEffect
EitherEffect.bindFuture Awaits and extracts a future Either
Future<Either>.bind Awaits and extracts itself

Guarding and short-circuiting

API Description
EitherEffect.ensure Requires a condition to be true
EitherEffect.ensureNotNull Extracts a non-null value or short-circuits
EitherEffect.raise Short-circuits directly with an available left value

Synchronous binding

The following complete example combines the main EitherEffect operations:

  • ensureNotNull extracts a required nullable value.
  • bind unwraps a Right or propagates an existing Left.
  • ensure checks a condition and short-circuits when it is false.
  • raise short-circuits with an available left value without constructing a Left solely to bind it.
Either<String, int> parseQuantity(String input) {
  final int? quantity = int.tryParse(input);
  return quantity == null
      ? Either.left('Quantity must be an integer')
      : Either.right(quantity);
}

Either<String, int> calculateOrderTotal({
  required String? quantityInput,
  required int unitPrice,
  required int availableStock,
}) =>
    Either.binding((effect) {
      // 1) Require the nullable input.
      final String input = effect.ensureNotNull(
        quantityInput,
        () => 'Quantity is required',
      );

      // 2) Bind an Either, propagating its Left automatically.
      final int quantity = effect.bind(parseQuantity(input));

      // 3) Enforce a value-level invariant.
      effect.ensure(quantity > 0, () => 'Quantity must be positive');

      // 4) Raise a domain error without constructing a Left to bind.
      if (quantity > availableStock) {
        effect.raise('Only $availableStock items are in stock');
      }

      // 5) A normal return becomes Right(total).
      return quantity * unitPrice;
    });

final Either<String, int> successfulOrder = calculateOrderTotal(
  quantityInput: '3',
  unitPrice: 20,
  availableStock: 10,
); // Right(60)

final Either<String, int> invalidQuantity = calculateOrderTotal(
  quantityInput: 'three',
  unitPrice: 20,
  availableStock: 10,
); // Left('Quantity must be an integer')

final Either<String, int> insufficientStock = calculateOrderTotal(
  quantityInput: '12',
  unitPrice: 20,
  availableStock: 10,
); // Left('Only 10 items are in stock')

raise returns Never, so it also works naturally in expressions such as nullable ?? effect.raise('missing').

Asynchronous binding

Either.bindingAsync opens an asynchronous binding scope. Inside that scope, ordinary await, local variables, conditions, and return remain available:

  • either.bind(effect) extracts a Right from an Either immediately.
  • await eitherFuture.bind(effect) waits for a Future<Either> and extracts its Right.
  • Both forms short-circuit the whole scope when they encounter a Left.
  • An error from a Future is not converted to a Left; it propagates through the future's error channel. Use Either.tryCatchAsync when it should become a typed Left instead.

This is the direct-style alternative to the Future<Either> pipelines in the next section. Use Either.binding for synchronous code and Either.bindingAsync as soon as the flow needs await. It is especially useful when several Either-producing operations depend on values produced by earlier steps: each value can be bound to a local variable, keeping the flow flat instead of nesting callbacks.

The shortened example below uses the shared AppError, toAppError, and model decoders from the complete runnable binding example.

Future<Either<AppError, dynamic>> httpGetAsEither(String uriString) =>
    Either.bindingAsync((effect) async {
      // A synchronous Either can be bound without await.
      final Uri uri = Either.tryCatch(
        action: () => Uri.parse(uriString),
        errorMapper: toAppError('Parse $uriString'),
      ).bind(effect);

      // A Future<Either> is awaited and then bound. tryCatchAsync converts
      // non-fatal Future errors into AppError values on the Left.
      final http.Response response = await Either.tryCatchAsync(
        action: () => http.get(uri),
        errorMapper: toAppError('http.get($uri)'),
      ).bind(effect);

      final int statusCode = response.statusCode;
      final String body = response.body;

      // A failed guard also short-circuits this bindingAsync scope.
      effect.ensure(
        statusCode >= 200 && statusCode < 300,
        () => AppError(
          HttpException(
            'statusCode=$statusCode, body=$body',
            uri: response.request?.url,
          ),
          StackTrace.current,
          'statusCode: $statusCode',
        ),
      );

      // Returning a plain value completes the scope with Right(value).
      return Either<AppError, dynamic>.tryCatch(
        action: () => jsonDecode(body),
        errorMapper: toAppError('jsonDecode: $body'),
      ).bind(effect);
    });

Either<AppError, BuiltList<User>> toUsers(dynamic list) { ... }

Either<AppError, BuiltList<User>> usersEither = await Either.bindingAsync(
  (effect) async {
    // Left from either operation exits this outer scope immediately.
    final dynamic json = await httpGetAsEither(
      'https://jsonplaceholder.typicode.com/users',
    ).bind(effect);
    final BuiltList<User> users = toUsers(json).bind(effect);
    return users;
  },
);

Migrating from deprecated binding APIs

The old name remains available in 2.x so existing code keeps working:

  • Either.futureBinding is deprecated in favor of Either.bindingAsync. The alias preserves the same callback, short-circuit, exception, and scope-lifetime semantics.

6. Pipelines on Future<Either<L, R>>

These extensions keep the outer Future and operate on the Right inside its Either. A Left skips the callback and passes through unchanged. An error from the source future or callback remains a future error.

Receiver Method Right-side operation
Future<Either<L, R>> thenMapEither R -> FutureOr<C>, then wrap the result in Right<C>
Future<Either<L, R>> thenFlatMapEither R -> FutureOr<Either<L, C>>, without nesting the result

Conceptually, these are map and flatMap for the concrete composition of Future and Either. The names make the layers explicit: then signals that the operation runs after the outer future completes, the middle verb describes the right-side operation, and the Either suffix distinguishes these helpers from operations on a plain Future.

Libraries with higher-kinded types can wrap the same shape in a monad transformer. Cats EitherT, for example, wraps F[Either[E, A]] and offers map/flatMap together with more specific variants. The corresponding Cats name depends on the callback shape: synchronous and asynchronous thenMapEither resemble map and semiflatMap; synchronous and asynchronous thenFlatMapEither resemble subflatMap and flatMapF. They are still the map-like and flatMap-like operations exposed by this concrete Dart API, rather than one-to-one copies of the Cats method names. The corresponding standard Haskell transformer is ExceptT e m a, which wraps m (Either e a) and composes through fmap and (>>=). dart_either does not emulate higher-kinded types or expose an EitherT, so it provides concrete, discoverable extensions for Future<Either<L, R>> instead.

Use these extensions when a short pipeline reads clearly. For a longer flow where later Either-producing operations depend on earlier results, Either.bindingAsync keeps the code flat and avoids nested callbacks. It also provides the same Left short-circuiting while allowing ordinary awaited values, guards, and local variables in direct async/await style. Either.binding is the synchronous counterpart and does not operate on Future<Either>.

The shortened example below uses the same shared model as the complete runnable pipeline example.

// 1) Define a reusable pipeline. thenFlatMapEither is used when the next
// operation already returns Either (or Future<Either>), so no nested Either
// is created.
Future<Either<AppError, dynamic>> httpGetAsEither(String uriString) {
  Either<AppError, dynamic> toJson(http.Response response) {
    final int statusCode = response.statusCode;
    final String body = response.body;

    return statusCode >= 200 && statusCode < 300
        ? Either<AppError, dynamic>.tryCatch(
            action: () => jsonDecode(body),
            errorMapper: toAppError('jsonDecode: body=$body'),
          )
        : Either<AppError, dynamic>.left(
            AppError(
              HttpException(
                'statusCode=$statusCode, body=$body',
                uri: response.request?.url,
              ),
              StackTrace.current,
              'statusCode: $statusCode',
            ),
          );
  }

  Future<Either<AppError, http.Response>> httpGet(Uri uri) =>
      Either.tryCatchAsync(
        action: () => http.get(uri),
        errorMapper: toAppError('http.get($uri)'),
      );

  final Future<Either<AppError, Uri>> uri = Future.value(
    Either.tryCatch(
      action: () => Uri.parse(uriString),
      errorMapper: toAppError('Parse $uriString'),
    ),
  );

  return uri.thenFlatMapEither(httpGet).thenFlatMapEither(toJson);
}

Either<AppError, BuiltList<User>> toUsers(dynamic list) { ... }

// 2) thenMapEither transforms a successful value. thenFlatMapEither then
// chains toUsers, which already returns Either, without nesting the result.
final Either<AppError, BuiltList<User>> usersEither =
    await httpGetAsEither('https://jsonplaceholder.typicode.com/users')
    .thenMapEither((dynamic json) => json as List)
    .thenFlatMapEither(toUsers);

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