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.
This article will review the basics of exception handling in Dart and Flutter (using try
and catch
) and explore how the Result
type can help us leverage the type system to handle errors more explicitly.
And in the next articles, we'll tackle more complex use cases where we need to run multiple asynchronous calls sequentially.
Ready? Let's go!
sponsor

Build and grow in-app purchases. Glassfy’s Flutter SDK solves all the complexities and edge cases of in-app purchases and subscriptions so you don't have to. Test and build for free today by clicking here.
Exception Handling in Dart and Flutter: The basics
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 SocketException catch (_) {
// make it explicit that a SocketException will be thrown if the network connection fails
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 SocketException
s that might occur if there is a network connection error.
And if we want to use our function, we can simply 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.
And that's 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 the Result type
What we really want is a way to make it explicit that the function can return a result that can be either success or an error.
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).
But in Dart, these features are unavailable, and we don't have a built-in Result
type.
And if we want, we can build our own using abstract classes and generics.
Or we can make our life easy and use the multiple_result package, which gives us a Result
type that we can use to specify Success
and Error
types.
As an alternative to multiple_result, you can use packages such as fpdart and dartz, which have an equivalent type called
Either
. There is also an official async package from the Dart team. All these packages use a slightly different syntax, but the concepts are the same.
Here's how we can convert our previous example to use it:
// 1. change the return type
Future<Result<Exception, 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);
// 2. return Success with the desired value
return Success(Location.fromMap(data));
default:
// 3. return Error with the desired exception
return Error(Exception(response.reasonPhrase));
}
} catch (e) { // catch all exceptions (not just SocketException)
// 4. return Error here too
return Error(e);
}
}
Now our function signature tells us exactly what the function does:
- return an
Exception
if something went wrong - return a success value with the resulting
Location
object if everything went well
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 when
method:
result.when(
(exception) => print(exception), // TODO: Handle exception
(location) => print(location), // TODO: Do something with location
);
This forces us to handle the error case explicitly, as omitting it would be a compiler error.
By using the
Result
type with pattern matching, we can leverage the Dart type system to our advantage and make sure we always handle errors.
The Result type: Benefits
Here's what we have learned so far:
- The
Result
type lets us explicitly declare success and error 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, as they make our code more robust and less error-prone.
So we can go ahead and use Result
everywhere, right?
Before we go ahead and refactor our entire codebase, let's dig a bit deeper and figure out when using Result
may not be a good idea.
When the Result type doesn't work well
Here is an example of a method that calls several other async methods internally:
Future<void> placeOrder() async {
try {
final uid = authRepository.currentUser!.uid;
// first await call
final cart = await cartRepository.fetchCart(uid);
final order = Order.fromCart(userId: uid, cart: cart);
// second await call
await ordersRepository.addOrder(uid, order);
// third await call
await cartRepository.setCart(uid, const Cart());
} catch (e) {
// TODO: Handle exceptions from any of the methods above
}
}
If any of the methods above throws an exception, we can catch it in one place and handle it as needed.
But suppose we converted each of the methods above to return a Future<Result>
.
In this case, the placeOrder()
method would look like this:
Future<Result<Exception, void>> placeOrder() async {
final uid = authRepository.currentUser!.uid;
// first await call
final result = await cartRepository.fetchCart(uid);
if (result.isSuccess()) {
final order = Order.fromCart(userId: uid, cart: result.getSuccess());
// second await call
final result = await ordersRepository.addOrder(uid, order);
if (result.isSuccess()) {
// third call (await not needed if we return the result)
return cartRepository.setCart(uid, const Cart());
} else {
return result.getError()!;
}
} else {
return result.getError()!;
}
}
This code is much harder to read because we have to unwrap each Result
object manually and write a lot of control flow logic to handle all the success/error cases.
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
).
Can we use Result with multiple async calls?
So when considering if you should convert a method to return a Future<Result>
, you could ask yourself if you're likely to call it in isolation or alongside other async functions.
But it's hard to decide this for every method you declare. And even if a method is called in isolation today, it may no longer be in the future.
What we really want is a way to capture the result of an asynchronous computation made of multiple async calls that could throw, and wrap it inside a Future<Result>
.
And that will be the topic of my next article, which will cover functional error handling in more detail.
Conclusion
Let's wrap up what we have learned so far.
The multiple_result package gives us a Result
type that lets us explicitly declare success and error types in the signature of a function or method in Dart:
// 1. change the return type
Future<Result<Exception, 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);
// 2. return Success with the desired value
return Success(Location.fromMap(data));
default:
// 3. return Error with the desired exception
return Error(Exception(response.reasonPhrase));
}
} on SocketException catch (e) {
// 4. return Error here too
return Error(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');
result.when(
(exception) => print(exception), // TODO: Handle exception
(location) => print(location), // TODO: Do something with location
);
However, we have an open question about how to use Result
if we have to call multiple async functions sequentially.
I'll cover this (and more) in upcoming articles about functional error handling:
I'll also show you some examples of how to handle errors in a medium-sized eCommerce app, such as the one I've covered in my latest course. 👇
New Flutter 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: