When we run a Flutter app, many things can go wrong.
The user may enter an incorrect input, a network request may fail, or we could have made a programmer mistake somewhere, and our app will crash.
Exception handling is a way of dealing with these potential errors in our code so our app can gracefully recover from them.
So in this article, we will review the basics of exception handling in Dart and Flutter (using try
and catch
).
And we'll explore how we can use sealed classes and pattern matching (introduced in Dart 3) to create a Result
type and leverage the type system to handle errors more explicitly.
We'll also talk about tradeoffs, so you can make an informed decision about when to use try
/catch
vs Result
to handle exceptions in your own apps.
Ready? Let's go!
Exception Handling with try/catch in Dart and Flutter
As an example, here's a simple Dart function that we can use to fetch a location from an IP address:
// get the location for a given IP using the http package
Future<Location> getLocationFromIP(String ipAddress) async {
try {
final uri = Uri.parse('http://ip-api.com/json/$ipAddress');
final response = await http.get(uri);
switch (response.statusCode) {
case 200:
final data = json.decode(response.body);
return Location.fromMap(data);
default:
throw Exception(response.reasonPhrase);
}
} on Exception catch (_) {
// make it explicit that this function can throw exceptions
rethrow;
}
}
In the code above, we're using the http package to make a GET request to an external API.
If the request is successful, we parse the response body as JSON and return a Location
object.
But if the request fails (for example, if the IP address is invalid or the API is down), we throw an exception.
We also wrap our code in a try
/catch
block to catch any exceptions that might occur if there are any network connection or JSON parsing errors.
If we want to use the function defined above, we can call it like this:
final location = await getLocationFromIP('122.1.4.122');
print(location);
But hang on there! If the function throws, we're going to get an unhandled exception.
To fix this, we need to wrap it in a try
/catch
block:
try {
final location = await getLocationFromIP('122.1.4.122');
print(location);
} catch (e) {
// TODO: handle exception, for example by showing an alert to the user
}
Now our code is more robust.
But it was too easy to forget to add the try
/catch
block in the first place because the signature of our function doesn't make it explicit that it can throw an exception:
Future<Location> getLocationFromIP(String ipAddress)
In fact, the only way to find out if the function throws is to read its documentation and implementation.
And if we have a large codebase, it can be even harder to figure out which functions might throw and which don't.
Improved Exception Handling with Sealed Classes and the Result type
What we really want is to make it explicit that the function can return a result that can be either success or a failure.
Languages such as Kotlin and Swift define their own Result
type using language features known as sealed classes (Kotlin) or enums with associated values (Swift).
And since Dart 3.0 also introduced sealed classes, we can create our own Result
type in a few lines of code:
/// Base Result class
/// [S] represents the type of the success value
sealed class Result<S> {
const Result();
}
final class Success<S> extends Result<S> {
const Success(this.value);
final S value;
}
final class Failure<S> extends Result<S> {
const Failure(this.exception);
final Exception exception;
}
The
Success
andFailure
classes above use thefinal
class modifier to prevent further subclassing. To learn more, read: Class modifiers. Also, note the use of generics to make the class more reusable.
If we want to get fancy, we can specify a second generic type E
with a type constraint:
/// Base Result class
/// [S] represents the type of the success value
/// [E] should be [Exception] or a subclass of it
sealed class Result<S, E extends Exception> {
const Result();
}
final class Success<S, E extends Exception> extends Result<S, E> {
const Success(this.value);
final S value;
}
final class Failure<S, E extends Exception> extends Result<S, E> {
const Failure(this.exception);
final E exception;
}
The
E extends Exception
constraint is useful if we want to only represent exceptions of typeException
or a subclass of it (which is the convention followed by all Flutter core libraries).
Updating the Return Type to use Result
Now that we have these new classes, we can convert our previous example to use them:
// 1. change the return type
Future<Result<Location, Exception>> getLocationFromIP(String ipAddress) async {
try {
final uri = Uri.parse('http://ip-api.com/json/$ipAddress');
final response = await http.get(uri);
switch (response.statusCode) {
case 200:
final data = json.decode(response.body);
// 2. return Success with the desired value
return Success(Location.fromMap(data));
default:
// 3. return Failure with the desired exception
return Failure(Exception(response.reasonPhrase));
}
} on Exception catch (e) {
// 4. return Failure here too
return Failure(e);
}
}
Note how the return type tells us exactly what the function does:
- return a
Success
with the resultingLocation
if everything went well - return a
Failure
containing anException
if something went wrong
Unwrapping a Result value with Pattern Matching and Switch Expressions
As a result, we can update our calling code like so:
final result = await getLocationFromIP('122.1.4.122');
And if we want to handle the result, we can use pattern matching with the new switch expression syntax introduced in Dart 3:
final value = switch (result) {
Success(value: final location) => location.toString(),
Failure(exception: final exception) => 'Something went wrong: $exception',
};
print(value);
This forces us to handle the error case explicitly, as omitting it would be a compiler error.
As a proof of this, if we comment out the Failure
case, we get this error:
In simple terms: we must handle all cases inside the switch statement.
The Result type: Benefits
Here's what we have learned so far:
- The
Result
type lets us explicitly declare success and failure types in the signature of a function or method in Dart - We can use pattern matching in the calling code to ensure we handle both cases explicitly
These are great benefits that make our code more robust and less error-prone.
So should we use the Result
type everywhere?
Before we go ahead and refactor our entire codebase, let's dig a bit deeper and figure out what limitations we may encounter when using Result
.
When the Result type doesn't work well
Consider this function that calls three other async functions sequentially:
Future<int> function1() { ... }
Future<int> function2(int value) { ... }
Future<int> function3(int value) { ... }
Future<int> complexAsyncWork() async {
try {
// first async call
final result1 = await function1();
// second async call
final result2 = await function2(result1);
// third async call (implicit)
return function3(result2);
} catch (e) {
// TODO: Handle exceptions from any of the methods above
}
}
As we can see, we need to await
for each function to return, so we can pass the resulting value to the next one.
And if any of the functions above throws an exception, we can catch it in one place and handle it as needed.
But suppose we converted each of the functions above to return a Future<Result>
.
In this case, the complexAsyncWork()
function might look like this:
Future<Result<int, Exception>> function1() { ... }
Future<Result<int, Exception>> function2(int value) { ... }
Future<Result<int, Exception>> function3(int value) { ... }
Future<Result<int, Exception>> complexAsyncWork() async {
// first async call
final result1 = await function1();
if (result1
case Success(value: final value1)) {
// second async call
final result2 = await function2(value1);
return switch (result2) {
// third async call
Success(value: final value2) => await function3(value2),
Failure(exception: final _) => result2, // error
};
} else {
return result1; // error
}
}
This code is much harder to read because we have to unwrap each Result
object manually and write some extra control flow logic to handle all the success/error cases.
The example above uses the new if-case and switch control flow expressions available in Dart 3.0.
In comparison, using try
/catch
makes it much easier to call multiple async methods sequentially (as long as the methods themselves throw exceptions rather than returning a Result
).
Bottom line: the
Result
type we have defined is too basic, and quickly becomes impractical when dealing with asynchronous code.
Functional Programming to the Rescue?
If you're serious about error handling, functional programming libraries like fpdart offer many useful things, including:
- more ergonomic APIs for chaining multiple functions using methods such as
flatMap
- better support for async programming using
TaskEither
Though be aware that functional programming has a steep learning curve and may not be worth it for simple apps.
In fact, there is one important tradeoff I want to highlight. 👇
Type Safety vs Flexibility
One benefit of Flutter's built-in exception-handling system is that it's lightweight. When a function throws, the exception will propagate through the call stack until it encounters a matching try
/catch
statement.
And if we forget to handle the exception altogether, we'll get a runtime error and the debugger will shout at us:
So let's remember that with try
/catch
, it's up to us to choose where we want to handle exceptions in the call stack.
But if we opt-in to use the Result
type everywhere, we must change the return type of all the methods in the call stack.
This can be tedious, but in return we get better type safety since it's a compiler error not to handle errors.
If we've adopted a layered app architecture, we should spend some time deciding which errors should be handled in the presentation, application, domain, and data layers. Indeed, designing a good error handling system can be very beneficial, especially in large codebases.
Conclusion
Let's summarize what we have learned so far.
With the introduction of Dart 3, we can use sealed classes to define a Result
type that lets us explicitly declare success and error types:
/// Base Result class
/// [S] represents the type of the success value
/// [E] should be [Exception] or a subclass of it
sealed class Result<S, E extends Exception> {
const Result();
}
final class Success<S, E extends Exception> extends Result<S, E> {
const Success(this.value);
final S value;
}
final class Failure<S, E extends Exception> extends Result<S, E> {
const Failure(this.exception);
final E exception;
}
We can use Result
in the signature of our Dart functions and methods:
// 1. change the return type
Future<Result<Location, Exception>> getLocationFromIP(String ipAddress) async {
try {
final uri = Uri.parse('http://ip-api.com/json/$ipAddress');
final response = await http.get(uri);
switch (response.statusCode) {
case 200:
final data = json.decode(response.body);
// 2. return Success with the desired value
return Success(Location.fromMap(data));
default:
// 3. return Failure with the desired exception
return Failure(Exception(response.reasonPhrase));
}
} on Exception catch (e) {
// 4. return Failure here too
return Failure(e);
}
}
And we can use pattern matching in the calling code to ensure we handle both cases explicitly:
// Use like this:
final result = await getLocationFromIP('122.1.4.122');
final value = switch (result) {
Success(value: final location) => location.toString(),
Failure(exception: final exception) => 'Something went wrong: $exception',
};
print(value);
However, our basic Result
type falls short when we try to call multiple async functions sequentially.
If we want to overcome these limitations, we can dive into functional programming and use packages like fpdart that can do all the heavy lifting for us. To learn more, you can read this follow-up article:
With that said, using try
/catch
is a versatile approach that works well with small-to-medium-sized apps, as long as we treat error handling as an important part of the app development process (and not an after-thought).
Flutter Foundations Course Now Available
I launched a brand new course that covers error handling in depth, along with other important topics like state management with Riverpod, app architecture, testing, and much more: