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.

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 and Failure classes above use the final 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 type Exception 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 resulting Location if everything went well
  • return a Failure containing an Exception 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:

If we forget to handle all cases, we'll get a non_exhaustive_switch_expression 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?

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 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:

An example exception that was not handled and caught at runtime
An example exception that was not handled and caught at runtime

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:

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

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

Flutter Animations Masterclass

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