Dart 72 🎯 Functional Programming in Dart: Monads, Either, and Option Types using fpdart
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
| Type | Meaning | Constructors |
|---|---|---|
Option<A> | Value may be absent | Some(a), None() |
Either<L, R> | Computation may fail | Left(l), Right(r) |
Task<A> | Lazy async, never fails | Task(() async => a) |
IO<A> | Lazy sync, never fails | IO(() => a) |
TaskEither<L, R> | Lazy async, may fail | TaskEither.tryCatch(...) |
IOEither<L, R> | Lazy sync, may fail | IOEither.tryCatch(...) |
Common Operations
| Operation | Option | Either |
|---|---|---|
| Transform value | map | map |
| Transform error | — | mapLeft |
| Chain | flatMap | flatMap |
| Extract with fallback | getOrElse | getOrElse |
| Pattern match | match | match |
| Wrap throwing function | Option.tryCatch | Either.tryCatch |
| Convert | toEither | toOption |
Do Notation
| Constructor | Purpose |
|---|---|
Option.Do | Chain Option computations linearly |
Either.Do | Chain Either computations linearly |
TaskEither.Do | Chain 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
Option.value throws on None | No seed or check | Use getOrElse or match |
Left ignored | getOrElse with null | Use match or fold |
TaskEither never runs | Forgot .run() | await taskEither.run() |
Exception escapes tryCatch | tryCatch not wrapping the throw | Wrap the throwing call |
Do notation $ confusion | $ is a function, not an operator | Use 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
| Item | Value |
|---|---|
| Package | fpdart |
| Option | Some or None |
| Either | Left (failure) or Right (success) |
| Task | Lazy async, never fails |
| IO | Lazy sync, never fails |
| TaskEither | Lazy async, may fail |
| tryCatch | Wrap throwing function |
| Do notation | Linear chaining with $ |
| Pattern matching | Native Dart switch works |
Key takeaways:
Optionrepresents absence without null.Some(value)for a present value,None()for an absent one.Option.tryCatchconverts exception-based parsing into anOption.Eitherrepresents 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 .TaskEitheris for asynchronous, fallible operations. It combinesTask(lazy async) withEither(fallible). ThetryCatchfactory wraps an async function and captures its exceptions asLeftvalues .flatMapis how the types compose. A chain offlatMapcalls short-circuits on the firstNoneorLeft, and the caller sees which step failed without a singletry/catch.- The Do notation makes chains readable.
Option.Do,Either.Do, andTaskEither.Doprovide 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.
fpdartprovides types and functions. It does not dictate architecture. The decision to useEitherin a repository orOptionin 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.
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!