gmana_functional

Pure Dart functional programming primitives for clean architecture.

import 'package:gmana_functional/gmana_functional.dart';

Table of contents


Either

Either<L, R> is a disjoint union with exactly two states:

Side Class Convention
Left Left<L, R> failure / error
Right Right<L, R> success / value

Construction

Either<String, int> ok    = const Right(42);
Either<String, int> fail  = const Left('Something went wrong');

Pattern matching — fold

The primary way to consume an Either. Always handles both sides.

final message = result.fold(
  (error) => 'Error: $error',
  (value) => 'Got: $value',
);

// Async variant
final message = await result.foldAsync(
  (error) async => await logAndFormat(error),
  (value) async => await render(value),
);

Transformation

// map — transform Right, pass Left through unchanged
final doubled = Right<String, int>(21).map((n) => n * 2);  // Right(42)
final noop    = Left<String, int>('err').map((n) => n * 2); // Left('err')

// mapAsync
final upper = await result.mapAsync((s) async => s.toUpperCase());

// mapLeft — transform Left, pass Right through
final wrapped = Left<String, int>('oops').mapLeft((e) => Failure(e));

// flatMap — chain operations that also return Either
Either<String, int> parse(String s) =>
    int.tryParse(s) != null ? Right(int.parse(s)) : Left('Not a number');

Either<String, double> divide(int n) =>
    n == 0 ? Left('Division by zero') : Right(100 / n);

final result = parse('5').flatMap(divide); // Right(20.0)
final error  = parse('0').flatMap(divide); // Left('Division by zero')
final bad    = parse('x').flatMap(divide); // Left('Not a number')

// flatMapAsync
final result = await fetchUser(id).flatMapAsync(
  (user) async => await fetchProfile(user.profileId),
);

// bimap — transform both sides at once
final mapped = result.bimap(
  (err) => 'Failure: $err',
  (val) => val * 2,
);

Extraction

// Safe — prefer fold or getOrElse in production code
result.rightOrNull()        // R? — null when Left
result.leftOrNull()         // L? — null when Right
result.getOrNull()          // alias for rightOrNull()
result.getOrElse((e) => 0)  // R — fallback computed from Left

// Unsafe — throw StateError on the wrong side
result.getRight()   // R  — throws if Left
result.getLeft()    // L  — throws if Right

Predicates

result.isRight()         // true for Right
result.isLeft()          // true for Left

result.contains(42)                   // true if Right(42)
result.exists((n) => n > 0)           // true if Right and value passes test
result.all((n) => n > 0)              // true if Left OR Right passes test

Side effects (tap)

Useful for logging or analytics without breaking a chain.

result
    .tap((value) => logger.info('Success: $value'))
    .tapLeft((error) => logger.error('Failure: $error'));

Swap

Right<String, int>(42).swap()   // Left<int, String>(42)
Left<String, int>('err').swap() // Right<int, String>('err')

Result & type aliases

Result<T> is the standard alias for Either<Failure, T>. Use it wherever an operation can fail with a Failure.

// Aliases
Result<T>       == Either<Failure, T>
ResultUnit      == Result<Unit>       // success with no value

FutureResult<T>     == Future<Result<T>>
FutureResultUnit    == FutureResult<Unit>

StreamResult<T>     == Stream<Result<T>>
StreamResultUnit    == StreamResult<Unit>
// Repository returning Result<User>
Future<Result<User>> fetchUser(String id) async {
  try {
    final data = await api.get('/users/$id');
    return Right(User.fromJson(data));
  } catch (e) {
    return Left(Failure('Failed to load user', code: 'user.fetch_failed'));
  }
}

// Caller
final result = await fetchUser('abc');
result.fold(
  (failure) => showError(failure.message),
  (user)    => showProfile(user),
);

Failure

Failure is the standard error carrier for the left side of a Result.

// Minimal
const Failure('Something went wrong')

// With a stable code for programmatic handling
const Failure('User not found', 'user.not_found')

// With structured metadata
Failure(
  'Validation failed',
  'validation.error',
  {'field': 'email', 'value': 'bad-input'},
)
// Consuming a Failure
result.fold(
  (f) => switch (f.code) {
    'user.not_found' => redirectToSignUp(),
    'network.timeout' => showRetry(),
    _ => showGenericError(f.message),
  },
  (user) => showProfile(user),
);

// Failure has value equality
Failure('msg', 'code') == Failure('msg', 'code') // true

Subclassing for domain errors

class NetworkFailure extends Failure {
  const NetworkFailure(super.message) : super('network.error');
}

class NotFoundFailure extends Failure {
  final String resource;
  NotFoundFailure(this.resource)
      : super('$resource not found', '$resource.not_found');
}

Unit

Unit (and its constant unit) represents successful completion with no meaningful value — the functional equivalent of void.

// Return unit when the operation succeeded but there is nothing to return
FutureResult<Unit> deleteUser(String id) async {
  try {
    await api.delete('/users/$id');
    return Right(unit);
  } catch (e) {
    return Left(Failure('Delete failed'));
  }
}

// Caller
final result = await deleteUser('abc');
result.fold(
  (f) => showError(f.message),
  (_) => showSuccess('User deleted'),   // _ is unit — no value needed
);
unit == unit    // true (singleton)
unit.toString() // '()'

UseCase & StreamUseCase

Interfaces for application-layer business logic following clean architecture.

UseCase<SuccessType, Params>

class GetUserUseCase implements UseCase<User, String> {
  final UserRepository _repo;
  const GetUserUseCase(this._repo);

  @override
  FutureResult<User> call(String id) => _repo.fetchUser(id);
}

// Usage
final useCase = GetUserUseCase(repository);
final result  = await useCase('user-123');

result.fold(
  (f) => handleError(f),
  (user) => render(user),
);

StreamUseCase<SuccessType, Params>

class WatchCartUseCase implements StreamUseCase<Cart, String> {
  final CartRepository _repo;
  const WatchCartUseCase(this._repo);

  @override
  StreamResult<Cart> call(String userId) => _repo.watchCart(userId);
}

// Usage
watchCart('user-123').listen((result) {
  result.fold(
    (f) => showError(f.message),
    (cart) => updateCartUI(cart),
  );
});

NoParams

Marker type for use cases that require no input parameter.

class GetCurrentUserUseCase implements UseCase<User, NoParams> {
  final AuthRepository _repo;
  const GetCurrentUserUseCase(this._repo);

  @override
  FutureResult<User> call(NoParams _) => _repo.currentUser();
}

// Usage
final result = await getCurrentUser(const NoParams());

Patterns & recipes

Chaining async operations

FutureResult<OrderSummary> placeOrder(Cart cart) async {
  return Right<Failure, Cart>(cart)
      .flatMap(validateCart)           // sync validation first
      .flatMapAsync(reserveInventory)  // then async
      .then((r) => r.flatMapAsync(processPayment))
      .then((r) => r.mapAsync(buildSummary));
}

Converting try/catch to Result

Result<T> tryResult<T>(T Function() action) {
  try {
    return Right(action());
  } catch (e) {
    return Left(Failure(e.toString()));
  }
}

Future<Result<T>> tryResultAsync<T>(Future<T> Function() action) async {
  try {
    return Right(await action());
  } catch (e) {
    return Left(Failure(e.toString()));
  }
}

Collecting multiple results

// Fail fast on the first Left
List<Either<String, int>> results = [Right(1), Right(2), Left('oops'), Right(4)];

final firstFailure = results.firstWhere((r) => r.isLeft(), orElse: () => Right(0));
final allValues    = results.whereType<Right<String, int>>().map((r) => r.value);

Using with Riverpod / state management

// AsyncNotifier that holds a Result
class UserNotifier extends AsyncNotifier<Result<User>> {
  @override
  Future<Result<User>> build() => ref.read(getUserUseCase)(const NoParams());

  Future<void> refresh() async {
    state = const AsyncLoading();
    state = AsyncData(await ref.read(getUserUseCase)(const NoParams()));
  }
}

Libraries

gmana_functional
Pure Dart functional programming primitives.