| |

Dart 72 🎯 Functional Programming in Dart: Monads, Either, and Option Types using fpdart

⏱️ Estimated read: 16 min | |

Dart’s type system is null-safe, but null safety only catches the absence of a value when the compiler can prove it. It does not prevent a function from throwing an exception, nor does it make the failure path visible in the type signature. A method that returns User might throw a NetworkException or a FormatException, and nothing in the signature warns the caller. fpdart addresses this by bringing the core types of functional programming to Dart: Option for values that may be missing, Either for computations that may fail, and Task/IO for computations that are lazy or asynchronous but never throw . These are not academic curiosities. They make failure and absence explicit in the type system, which means the compiler forces the caller to handle them.

The library describes itself as providing “all the main functional programming types and patterns fully documented, tested, and with examples” . It is inspired by fp-ts and cats, and it does not require prior functional programming experience to use . The types compose: Option and Either can be chained with flatMap, converted into each other with toEither and toOption, and combined with Task to form TaskEither for asynchronous fallible operations .

Key point: Option represents a value that may be absent (Some or None), Either represents a computation that may fail (Right for success, Left for failure), and both force the caller to handle the absence or failure path explicitly; fpdart provides these types along with Task (lazy async), IO (lazy sync), and composite types like TaskEither for asynchronous fallible operations.


Why Option and Either exist in a null-safe language

The exception problem. Dart’s exceptions are unchecked. A function that declares Future<User> fetchUser() can throw at any point, and the caller has no compile-time indication that it might. The try/catch is optional, and the compiler will not complain if it is omitted. In a large codebase, this means failure paths are invisible in the type signatures, and the only documentation of what can go wrong is the implementation itself. Either<L, R> makes the failure explicit: the return type is Either<Error, User>, and the caller must pattern-match or call fold/match to extract the value. The compiler enforces handling .

The null problem. Dart’s null safety is a significant improvement, but it has limits. A Map<String, User> returns User? when you look up a key that might not exist. That is fine. But a function that returns User? does not distinguish between “the user was not found” and “the database query failed”—both are null. Option<User> distinguishes absence from failure: None means “no value,” and an exception (or an Either) means “something went wrong.” The distinction matters when the caller’s response differs: a missing user might show a “not found” page, while a database failure might show a “try again” error.

The composition problem. Null checks and try/catch blocks compose poorly. A chain of three functions that each return T? requires three null checks. A chain of three functions that each throw requires one try/catch around the whole chain, which loses the information about which step failed. Option and Either compose through flatMap: the chain short-circuits on the first None or Left, and the caller sees which step failed without a single try/catch . The fpdart documentation shows the flatMap chain as the primary way to compose computations that may fail or be absent .

The explicit error type problem. An exception carries a stack trace and a message, but the type system does not know what kinds of exceptions a function throws. Either<NetworkError, User> says exactly what can go wrong. The Left type is part of the signature, and the caller can switch on it exhaustively. This is the same advantage that Result has in Rust and Either has in Haskell, and it is the reason fpdart exists for Dart .


a. Option: representing absence without null

Option<A> has two constructors: Some(value) for a present value, and None() for an absent one. The Option.of(value) factory creates a Some if the value is non-null, and Option.none() creates a None .

// Import the fpdart package
import 'package:fpdart/fpdart.dart';

void main() {
  // Create an Option with a value
  // Option.of returns Some(10) because 10 is not null
  final some = Option.of(10);

  // Create an empty Option
  // Option<int>.none() returns None
  final none = Option<int>.none();

  // Extract the value with a fallback
  // getOrElse returns the value if Some, or the fallback if None
  final value = some.getOrElse(() => -1); // 10
  final fallback = none.getOrElse(() => -1); // -1

  // Map transforms the value inside Some
  // None stays None without calling the function
  final mapped = some.map((a) => 'Value: $a'); // Some('Value: 10')

  // flatMap chains computations that also return Option
  // If the current Option is None, the function is not called
  final chained = some.flatMap((a) => Option.of(a + 10)); // Some(20)

  // Pattern matching with match
  // The first function handles Some, the second handles None
  some.match(
    (a) => print('Some($a)'), // prints Some(10)
    () => print('None'),
  );
}

The Option.tryCatch factory is the bridge from exception-based code to Option-based code. It runs a function, returns Some(result) if the function succeeds, and None() if it throws .

// tryCatch runs the function and wraps the result
// If int.parse throws a FormatException, the result is None
final parsed = Option.tryCatch(() => int.parse('invalid'));
// parsed is None because 'invalid' cannot be parsed to an int

final valid = Option.tryCatch(() => int.parse('42'));
// valid is Some(42)

The toEither conversion is how an Option becomes an Either when the caller needs to know why the value was absent. The function passed to toEither provides the Left value for the None case .

// Convert None to Left with a reason
final either = none.toEither(() => 'Value was missing');
// either is Left('Value was missing')

b. Either: representing failure without exceptions

Either<L, R> has two constructors: Left(value) for a failure with an error of type L, and Right(value) for a success with a value of type R. The convention is that Right is the success path and Left is the failure path .

// Create a Right (success)
// Either<String, int> means the error type is String, the success type is int
final right = Either<String, int>.of(10);

// Create a Left (failure)
final left = Either<String, int>.left('Something went wrong');

// Map transforms the Right value
// Left is passed through unchanged
final mapped = right.map((a) => 'Value: $a'); // Right('Value: 10')

// mapLeft transforms the Left value
// Right is passed through unchanged
final mappedLeft = left.mapLeft((e) => e.length); // Left(20)

// flatMap chains computations that return Either
// A Left short-circuits the chain
final chained = right.flatMap((a) => Either<String, int>.of(a + 10));
// chained is Right(20)

// match handles both cases
// The first function handles Left, the second handles Right
right.match(
  (l) => print('Left($l)'),
  (r) => print('Right($r)'), // prints Right(10)
);

// Dart's native pattern matching works on Either as well
final description = switch (right) {
  Left(value: final l) => 'Left($l)',
  Right(value: final r) => 'Right($r)',
};

The Either.tryCatch factory wraps a function that may throw. It returns Right(result) if the function succeeds, and Left(error) if it throws, using the provided function to convert the exception and stack trace into the Left type .

// Wrap a throwing function in Either
// The second argument converts the exception and stack trace to the Left type
final result = Either<String, int>.tryCatch(
  () => int.parse('invalid'),
  (error, stackTrace) => 'Parse error: $error',
);
// result is Left('Parse error: FormatException: Invalid radix-10 number')

The getOrElse method extracts the Right value or returns a fallback computed from the Left value. This is useful when the caller has a sensible default and does not need to distinguish the failure path .

// Extract the Right value, or compute a fallback from the Left
final value = right.getOrElse((l) => -1); // 10
final fallback = left.getOrElse((l) => -1); // -1

c. Task, TaskEither, and the composition of async and fallible

Task<A> is a wrapper around a Future<A> that is lazy: the computation does not start until run() is called. It is for asynchronous computations that never fail. TaskEither<L, R> combines Task with Either: it is an asynchronous computation that either yields an R or fails with an L .

// Create a Task from an async function
// The function does not run until run() is called
final task = Task(() async {
  await Future.delayed(Duration(seconds: 1));
  return 42;
});

// Map transforms the result inside the Task
final mapped = task.map((a) => a * 2);

// Run the Task to get the Future and await it
final result = await mapped.run(); // 84

TaskEither is the type that a repository or API client should return when the operation is asynchronous and fallible. It makes both the async nature and the failure possibility explicit in the return type .

// A function that fetches a user and may fail
// The return type says: async, may fail with String, yields User on success
TaskEither<String, User> fetchUser(String id) {
  return TaskEither.tryCatch(
    () async {
      final response = await http.get(Uri.parse('/users/$id'));
      if (response.statusCode != 200) {
        throw Exception('HTTP ${response.statusCode}');
      }
      return User.fromJson(jsonDecode(response.body));
    },
    (error, stackTrace) => 'Failed to fetch user: $error',
  );
}

The TaskEither.tryCatch factory wraps the async function and captures any exception as a Left. The caller can chain more TaskEither operations with flatMap, and any failure in the chain short-circuits to the Left value .

The Do notation, introduced in fpdart v0.6.0, makes chaining multiple TaskEither or Option computations more readable. Instead of nested flatMap calls, the Do constructor provides a $ function that extracts values from the monad, and the chain is written linearly .

// Without Do notation: nested flatMap
Option<int> addWithoutDo() {
  return Option.of(1).flatMap((a) =>
    Option.of(2).flatMap((b) =>
      Option.of(a + b)
    )
  );
}

// With Do notation: linear extraction
Option<int> addWithDo() {
  return Option.Do((_) {
    // $ extracts the value from each Option
    // if any is None, the whole block short-circuits to None
    final a = $(Option.of(1));
    final b = $(Option.of(2));
    return Option.of(a + b);
  });
}

The $ function is the Do notation’s mechanism for unwrapping. It is not a magic operator; it is a function provided by the Do block that either returns the value inside the monad or short-circuits the entire block .


Complete Example Session

The following session builds a user repository that fetches a user, parses the response, and validates the user’s age, using TaskEither for the async and fallible parts and Option for the parseable fields.

// ============================================
// PART 1: THE DOMAIN MODEL
// ============================================
// A simple User class with a nullable field
class User {
  final String id;
  final String name;
  // age is nullable because the API may not provide it
  final int? age;

  User({required this.id, required this.name, this.age});
}

// ============================================
// PART 2: THE PARSING FUNCTION
// ============================================
// Parse a JSON map into a User
// Returns Either<String, User>: Left for parse errors, Right for success
Either<String, User> parseUser(Map<String, dynamic> json) {
  // The id and name are required; if either is missing, fail
  final id = json['id'] as String?;
  final name = json['name'] as String?;

  if (id == null || name == null) {
    return Left('Missing required fields: id=$id, name=$name');
  }

  // The age is optional; parse it with Option.tryCatch
  final age = Option.tryCatch(() => json['age'] as int).getOrElse(() => null);

  return Right(User(id: id, name: name, age: age));
}

// ============================================
// PART 3: THE ASYNC FETCH FUNCTION
// ============================================
// Fetch a user by ID from an API
// Returns TaskEither<String, User>: async, may fail with String
TaskEither<String, User> fetchUser(String id) {
  return TaskEither.tryCatch(
    () async {
      // Simulate an HTTP call
      final response = await http.get(Uri.parse('/api/users/$id'));
      if (response.statusCode != 200) {
        throw Exception('HTTP ${response.statusCode}');
      }
      // Parse the JSON and then parse the User
      final json = jsonDecode(response.body) as Map<String, dynamic>;
      // parseUser returns Either<String, User>
      // toTaskEither lifts the Either into the TaskEither
      return parseUser(json).getOrElse((error) => throw Exception(error));
    },
    (error, stackTrace) => 'Fetch failed: $error',
  );
}

// ============================================
// PART 4: USING THE RESULT
// ============================================
void main() async {
  // fetchUser returns a TaskEither
  // run() returns a Future<Either<String, User>>
  final result = await fetchUser('42').run();

  // match handles both the Left and Right cases
  result.match(
    (error) => print('Error: $error'),
    (user) => print('User: ${user.name}, age: ${user.age ?? 'unknown'}'),
  );

  // Alternatively, use Dart's pattern matching
  final message = switch (result) {
    Left(value: final error) => 'Error: $error',
    Right(value: final user) => 'User: ${user.name}',
  };
  print(message);
}

The four parts cover the domain model with a nullable field, the parsing function that returns Either, the async fetch function that returns TaskEither, and the consumption of the result with match and pattern matching. What the session demonstrates is that the failure path is in the type signature at every step: parseUser returns Either<String, User>, fetchUser returns TaskEither<String, User>, and the caller cannot extract the User without handling the String error.


Quick Reference

Core Types

TypeMeaningConstructors
Option<A>Value may be absentSome(a), None()
Either<L, R>Computation may failLeft(l), Right(r)
Task<A>Lazy async, never failsTask(() async => a)
IO<A>Lazy sync, never failsIO(() => a)
TaskEither<L, R>Lazy async, may failTaskEither.tryCatch(...)
IOEither<L, R>Lazy sync, may failIOEither.tryCatch(...)

Common Operations

OperationOptionEither
Transform valuemapmap
Transform error—mapLeft
ChainflatMapflatMap
Extract with fallbackgetOrElsegetOrElse
Pattern matchmatchmatch
Wrap throwing functionOption.tryCatchEither.tryCatch
ConverttoEithertoOption

Do Notation

ConstructorPurpose
Option.DoChain Option computations linearly
Either.DoChain Either computations linearly
TaskEither.DoChain TaskEither computations linearly
$Extracts the value, short-circuits on failure

Best Practices

✅ Do This:

// Return Either from functions that can fail
Either<String, User> parseUser(Map<String, dynamic> json) { ... }

// Use TaskEither for async fallible operations
TaskEither<String, User> fetchUser(String id) { ... }

// Use match or pattern matching to handle both paths
result.match((error) => ..., (user) => ...);

// Use tryCatch to wrap exception-based code
Either.tryCatch(() => int.parse(s), (e, st) => 'Parse error: $e');

❌ Don’t Do This:

// Throw exceptions from functions that return Either
Either<String, User> parseUser(...) {
  throw Exception('missing'); // defeats the purpose
}

// Ignore the Left case
final user = result.getOrElse((_) => null); // loses the error

// Use Option for operations that can fail (not just be absent)
// Option<User> fetchUser(...) // "failed" and "not found" are conflated

Common Pitfalls

PitfallWhy It HappensFix
Option.value throws on NoneNo seed or checkUse getOrElse or match
Left ignoredgetOrElse with nullUse match or fold
TaskEither never runsForgot .run()await taskEither.run()
Exception escapes tryCatchtryCatch not wrapping the throwWrap the throwing call
Do notation $ confusion$ is a function, not an operatorUse inside the Do block

Real-World Examples

1. Option from parsing

final parsed = Option.tryCatch(() => int.parse('42')); // Some(42)

2. Option to Either

final either = none.toEither(() => 'missing'); // Left('missing')

3. Either from API call

Either<String, User> result = api.fetchUser(id);

4. Chain with flatMap

final user = fetchUser(id).flatMap((u) => fetchPosts(u.id));

5. TaskEither for async

TaskEither<String, User> fetchUser(String id) => TaskEither.tryCatch(...);

6. Match on Either

result.match((error) => print(error), (user) => print(user.name));

7. Do notation

Option.Do((_) { final a = $(optA); final b = $(optB); return Option.of(a + b); });

8. getOrElse fallback

final value = result.getOrElse((error) => defaultValue);

9. Pattern matching

switch (result) { Left(value: final e) => ..., Right(value: final v) => ... }

10. tryCatch for exceptions

Either.tryCatch(() => jsonDecode(s), (e, st) => 'Invalid JSON');

Visual

Option and Either Flow

┌──────────────────────────────────────────────┐
│  OPTION                                      │
│  Some(value) ──map──▶ Some(newValue)         │
│  Some(value) ──flatMap──▶ Some/None          │
│  None ──map──▶ None (function not called)    │
│  None ──flatMap──▶ None (function not called)│
│                                              │
│  EITHER                                      │
│  Right(value) ──map──▶ Right(newValue)       │
│  Right(value) ──flatMap──▶ Right/Left        │
│  Left(error) ──map──▶ Left (unchanged)       │
│  Left(error) ──flatMap──▶ Left (unchanged)   │
└──────────────────────────────────────────────┘

TaskEither Composition

┌──────────────────────────────────────────────┐
│  fetchUser(id) : TaskEither<String, User>    │
│         │                                    │
│         ▼                                    │
│  .flatMap((user) => fetchPosts(user.id))     │
│         │                                    │
│         ▼                                    │
│  TaskEither<String, List<Post>>              │
│         │                                    │
│         ▼                                    │
│  .run() → Future<Either<String, List<Post>>> │
│         │                                    │
│         ▼                                    │
│  .match(error => ..., posts => ...)          │
└──────────────────────────────────────────────┘

The Do Notation

┌──────────────────────────────────────────────┐
│  Option.Do((_) {                             │
│    final a = $(Option.of(1));  ← Some(1)     │
│    final b = $(Option.of(2));  ← Some(2)     │
│    return Option.of(a + b);    ← Some(3)     │
│  });                                         │
│                                              │
│  If any $() is None, the block returns None  │
│  without executing the rest.                 │
└──────────────────────────────────────────────┘

Exception vs Either

┌──────────────────────────────────────────────┐
│  EXCEPTION                                   │
│  User fetchUser()                            │
│  → signature does not say it can fail        │
│  → caller may forget try/catch               │
│                                              │
│  EITHER                                      │
│  Either<String, User> fetchUser()            │
│  → failure is in the type signature          │
│  → caller must handle Left or Right          │
└──────────────────────────────────────────────┘

Summary

ItemValue
Packagefpdart
OptionSome or None
EitherLeft (failure) or Right (success)
TaskLazy async, never fails
IOLazy sync, never fails
TaskEitherLazy async, may fail
tryCatchWrap throwing function
Do notationLinear chaining with $
Pattern matchingNative Dart switch works

Key takeaways:

  • Option represents absence without null. Some(value) for a present value, None() for an absent one. Option.tryCatch converts exception-based parsing into an Option .
  • Either represents failure without exceptions. Left(error) for failure, Right(value) for success. The failure type is in the signature, and the compiler forces the caller to handle it .
  • TaskEither is for asynchronous, fallible operations. It combines Task (lazy async) with Either (fallible). The tryCatch factory wraps an async function and captures its exceptions as Left values .
  • flatMap is how the types compose. A chain of flatMap calls short-circuits on the first None or Left, and the caller sees which step failed without a single try/catch .
  • The Do notation makes chains readable. Option.Do, Either.Do, and TaskEither.Do provide a $ function that extracts values linearly, with automatic short-circuiting on failure .
  • Dart’s pattern matching works on these types. switch (result) { Left(value: final e) => ..., Right(value: final v) => ... } is exhaustive and requires no library-specific syntax .
  • The library is not a framework. fpdart provides types and functions. It does not dictate architecture. The decision to use Either in a repository or Option in a parser is made function by function .

Remember: fpdart brings the vocabulary of functional programming to Dart: Option for absence, Either for failure, Task for lazy async, and TaskEither for both at once. The value is not the types themselves—it is the fact that they make absence and failure visible in the type signatures. A function that returns Either<String, User> tells the caller that failure is possible and that the failure is a String. The caller cannot extract the User without handling the String. This is the same guarantee that exceptions provide at runtime, but enforced at compile time. The cost is verbosity: flatMap chains and match calls are longer than try/catch. The benefit is that the failure paths are documented, tested, and impossible to forget.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!