The Dart language offers primitives such as try
, catch
, and throw
for handling errors and exceptions in our apps.
But as we have learned in the previous article, it's easy to write unsafe code if we rely on the built-in exception handling system:
- we can only find out if a function can throw by reading its documentation and/or implementation
- we can't leverage static analysis and the type system to handle errors explicitly
We can overcome some of these issues by applying some functional programming principles to error handling and writing safer and more robust code.
And in this article, we'll focus on error handling by learning about the Either
type from the fpdart package.
Since there is much to cover, here will focus on the basics. And in the next article, we'll learn how to deal with errors and asynchronous code in real-world Flutter apps using TaskEither
.
In the previous article about Flutter Exception Handling, we have already seen how to use the
Result
type to handle errors more explicitly. As we'll see,Either
andResult
are very similar, and the fpdart package offers additional APIs that help us go beyond the limitations of theResult
type.
So let's get started with a brief overview of functional programming.
What is Functional Programming?
Functional programming (or FP) is a fascinating topic that promotes using pure functions, immutable data, and a declarative programming style, helping us write more clean and maintainable code.
This is in contrast to object-oriented programming (OOP), which relies on mutable state and an imperative programming style to describe the data and behavior of the objects in our system.
Since many modern languages support both functional and object-oriented paradigms, you can adopt one style or the other as you see fit in your code.
In fact, you may have already used FP in your Dart and Flutter code by:
- passing a function as an argument to another function (such as a callback)
- using the
map
,where
,reduce
functional operators onIterable
types such as lists and streams - working with generics and type inference
Other functional programming features such as pattern matching, destructuring, multiple return values, and higher-order types have already been discussed in the Dart language funnel.
And error handling is one area where FP can bring substantial benefits.
So let's dive in, starting with a small example. 👇
Example: Parsing a Number
If we want to parse a String
containing a numerical value into a double
, we can write code like this:
final value = double.parse('123.45'); // ok
However, what would happen if we tried running this?
final value = double.parse('not-a-number'); // throws exception
This code throws an exception at runtime.
But the signature of the parse
function doesn't tell us this, and we have to read the documentation to find out:
/// Parse [source] as a double literal and return its value.
/// Throws a [FormatException] if the [source] string is not valid
static double parse(String source);
If we want to handle the FormatException
, we can use a try
/catch
block:
try {
final value = double.parse('not-a-number');
// handle success
} on FormatException catch (e) {
// handle error
print(e);
}
But on large codebases, it's hard to figure out which functions might throw and which don't.
Ideally, we want the signature of our functions to make it explicit that they can return an error.
The Either type
The Either
type from the fpdart package lets us specify both the failure and success types as part of the function signature:
import 'package:fpdart/fpdart.dart';
Either<FormatException, double> parseNumber(String value) {
try {
return Either.right(double.parse(value));
} on FormatException catch (e) {
return Either.left(e);
}
}
The values inside
Either.left
andError.right
must match the type annotations we have defined (FormatException
anddouble
in this case). Always useEither.left
to represent errors, andEither.right
to represent the return value (success).
Comparing Either and Result
At first sight, Either
is very similar to the Result
type that is available in the multiple_result package:
Result<FormatException, double> parseNumber(String value) {
try {
return Success(double.parse(value));
} on FormatException catch (e) {
return Error(e);
}
}
In fact, only the basic syntax changes:
Either.right
↔Success
Either.left
↔Error
But Either
has a much more extensive and powerful API. Let's take a look. 👇
Either and the tryCatch factory constructor
If we want to simplify our implementation, we can use the tryCatch
factory constructor:
Either<FormatException, double> parseNumber(String value) {
return Either.tryCatch(
() => double.parse(value),
(e, _) => e as FormatException,
);
}
This is how tryCatch
is implemented:
/// Try to execute `run`. If no error occurs, then return [Right].
/// Otherwise return [Left] containing the result of `onError`.
factory Either.tryCatch(
R Function() run, L Function(Object o, StackTrace s) onError) {
try {
return Either.of(run());
} catch (e, s) {
return Either.left(onError(e, s));
}
}
Note that the onError
callback gives the error and stack trace as arguments, and the error type is Object
.
But since we know that the double.parse
function can only ever throw a FormatException
, it's safe to cast e
as a FormatException
in our parseNumber
function.
Fun Example: Functional Fizz-Buzz 😎
Now that we've seen how to use Either<E, S>
to represent errors in our code, let's take a step forward and use it to manipulate some data.
For example, here is a Dart function that implements the popular fizz buzz algorithm:
String fizzBuzz(double value) {
if (value % 3 == 0 && value % 5 == 0) {
// multiple of 3 and 5
return 'fizz buzz';
} else if (value % 3 == 0) {
// multiple of 3
return 'fizz';
} else if (value % 5 == 0) {
// multiple of 5
return 'buzz';
} else {
// all other numbers
return value.toString();
}
}
And suppose that we want to implement a function defined as follows:
Iterable<Either<FormatException, String>>
parseFizzBuzz(List<String> strings);
This function should return an Iterable
list of values by:
- parsing each of the strings in the input list with the
parseNumber
function - if the number is valid, apply the
fizzBuzz
function to it and returnEither.right
with the result - if the number is not valid, return
Either.left
with aFormatException
Fizz-buzz: Imperative style
As a first attempt, we could implement this with some imperative code:
Iterable<Either<FormatException, String>>
parseFizzBuzz(List<String> strings) {
// all types are declared explicitly for clarity,
// but we could have used `final` instead:
List<Either<FormatException, String>> results = [];
for (String string in strings) {
// first, parse the input string
Either<FormatException, double> parsed = parseNumber(string);
// then, use map to convert valid numbers using [fizzBuzz]
Either<FormatException, String> result =
parsed.map((value) => fizzBuzz(value));
// add the value
results.add(result);
}
return results;
}
The most interesting line is this:
Either<FormatException, String> result =
parsed.map((value) => fizzBuzz(value));
Here we use the Either.map
operator to convert all values (of type double
) to results (of type String
) by calling the fizzBuzz
function.
If we check the documentation, we discover that Either.map
is only applied if Either
is Right
, meaning that any errors are carried over automatically:
/// If the [Either] is [Right], then change its value from type `R` to
/// type `C` using function `f`.
Either<L, C> map<C>(C Function(R a) f);
Fizz-buzz: Functional style
Guess what?
We can implement the parseFizzBuzz
function in a completely functional style:
Iterable<Either<FormatException, String>>
parseFizzBuzz(List<String> strings) => strings.map(
(string) => parseNumber(string).map(
(value) => fizzBuzz(value) // no tear-off
)
);
And we can make it even shorter using a tear-off:
Iterable<Either<FormatException, String>>
parseFizzBuzz(List<String> strings) => strings.map(
(string) => parseNumber(string).map(
fizzBuzz // with tear-off
)
);
Either way (pun intended 😅), we have replaced the imperative for loop by mapping over the list of strings.
And the best thing is that errors don't get in the way when we map values using Either
.
Running the Fizz-Buzz implementation
As proof of this, let's run this code:
void main() {
final values = [
'1', '2', '3', '4', '5',
'6', '7', '8', '9', '10',
'11', '12', '13', '14', '15',
'not-a-number', 'invalid',
];
// parse, then join by inserting a newline
final results = parseFizzBuzz(values).join('\n');
print(results);
}
And this is the output:
Right(1.0)
Right(2.0)
Right(fizz)
Right(4.0)
Right(buzz)
Right(fizz)
Right(7.0)
Right(8.0)
Right(fizz)
Right(buzz)
Right(11.0)
Right(fizz)
Right(13.0)
Right(14.0)
Right(fizz buzz)
Left(FormatException: Invalid double not-a-number)
Left(FormatException: Invalid double invalid)
Cool, isn't it? 😎
What else can we do with Either?
Want to create an instance of Either
directly from a left or right value?
/// Create an instance of [Right]
final right = Either<String, int>.of(10);
/// Create an instance of [Left]
final left = Either<String, int>.left('none');
How about mapping values?
/// Map the right value to a [String]
final mapRight = right.map((a) => '$a');
/// Map the left value to a [int]
final mapLeft = right.mapLeft((a) => a.length);
Fancy some pattern matching?
/// Pattern matching
final number = parseNumber('invalid');
/// unwrap error/success with the fold method
number.fold( // same as number.match()
(exception) => print(exception),
(value) => print(value),
);
I won't list all the things you can do with
Either
here. Take a peek at the documentation to learn about the 40+ methods you can use to create and manipulate values.
And since all these methods are composable, we can create complex functions out of simple ones. To learn more about this, read this article about chaining functions by the author of fpdart.
The fpdart package is fully documented and you don't need any previous experience with functional programming to start using it. Check the official documentation for more details.
Conclusion
We've now ventured into the world of functional programming, by learning about Either
and the fpdart package.
Here are the key points:
- we can use
Either<L, R>
as an alternative to throwing exceptions whenever we want to declare errors explicitly in the signature of our functions/methods (leading to self-documenting code). - if we use
Either
and don't handle errors, our code won't compile. This is much better than discovering errors at runtime during development (or worse, in production 🥶). Either
comes with an extensive API, making it easy to manipulate our data with useful functional operators such asmap
,mapLeft
,fold
, and many others.
The examples I presented were academic on purpose, making it easier to cover the basics.
But in the next article, we'll learn how to deal with errors and asynchronous code in real-world Flutter apps using TaskEither
.
And we'll see how to make the most of fpdart and TaskEither
when we use a reference app architecture with presentation, application, domain, and data layers, which is well suited for medium and large-sized apps. 👍
Flutter Foundations Course Now Available
I launched a brand new course that covers error handling in great depth, along with other important topics like state management with Riverpod, app architecture, testing, and much more: