Flutter Exception Handling with try/catch and the Result type

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!

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 SocketExceptions 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?

A noob dev who just discovered the Result type
A noob dev who just discovered the Result type

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:

Want More?

Invest in yourself with my high-quality Flutter courses.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.

Flutter Animations Masterclass - Full Course

Flutter Animations Masterclass - Full Course

Master Flutter animations and build a completely custom habit tracking application.