An approach to error handling on Flutter

Intro

In the day-to-day of any app, we have to deal with errors and exceptions that happen if something doesn't go as expected. This article aims to bring some approaches to dealing with these errors in Flutter.

Imagine a scenario where we have to make a simple call to a Notes API to display to the user. We will discuss different approaches to catching possible errors when performing this task.

class NotesService {
  final client = AnyHttpClient();
  Future<List<Note>> getNotes() async {
    try {
      final response = await client.get('notes');
      return (json.decode(response.body) as List).map((json) =>
              Note.fromJson(json)).toList();
    } catch (e) {
      print(e);
    }
  }
}

1. Encapsulating Success or Error

The most common approach would be to have a class like this where we would keep the error or the data received.

class ResultWrapper<T> {
  ResultWrapper.success(this.data) : failure = null;
  ResultWrapper.failure(this.failure) : data = null;

  final T data;
  final Failure failure;

  bool get dataExists  => data != null;
}

abstract class Failure {
  final String message;
}

Then we can define a subset of possible Failure types.

class NotesService {
  final client = AnyHttpClient();
  Future<ResultWrapper<List<Note>>> getNotes() async {
    try {
      final response = await client.get('notes');
      final list =  (json.decode(response.body) as List).map((json) =>
              Note.fromJson(json)).toList();
            return ResultWrapper.success(list);
    } on SocketException {
      return ResultWrapper.failure(NoInternetFailure('No Internet connection 😑'));
    } on HttpException {
      return ResultWrapper.failure(ServerFailure("Couldn't find the post 😱"));
    } on FormatException {
      return ResultWrapper.failure(ServerFailure("Bad format 👎"));
    }
  }
}

Any NotesService client would have to validate using the ResultWrapper

final service = NotesService()
final result = await service.getNotes();
if(result.dataExists){
    final notes = result.data;
    // do something with notes list
}
final errorMessage = result.failure.message;
// or do something with error message

2. Functional approach

The Dart language has functional language features like high-order functions since functions are also objects. Although the language allows the functional paradigm, it doesn't have a standard library that makes the dev's life easier. That's why the most used third party lib is Dartz.

When working with the functional paradigm, we basically look at three elements: Functions, in its purest mathematical sense of input, data transformation, and output; Types, as a possible set of values; And Composition where addition or product operations can be performed on Types.

Going back to our initial scenario, if we think about some types resulting from the getNotes() function we can identify the success type (the List<Note>) and the failure type (Failure).

So, the functional paradigm allows us to make a sum composition of these two types through the Either class. The idea of ​​adding types is intuitive when we think of the logical OR operator. Adding type A to type B allows one or the other, but not both. Hence the name Either.

To learn more about the functional paradigm I recommend this course free on coursera.

The function's implementation is slightly modified. Either takes the two possible types using generics, one on the left and one on the right. That's why we use Left and Right in the return.

*By convention, the type that represents failure or error is on the Left side.

class NotesService {
  final client = AnyHttpClient();
  Future<Either<Failure,List<Note>>>> getNotes() async {
    try {
      final response = await client.get('notes');
      final list =  (json.decode(response.body) as List).map((json) =>
              Note.fromJson(json)).toList();
            return Right(list);
    } on SocketException {
      return Left(NoInternetFailure('No Internet connection 😑'));
    } on HttpException {
      return Left(ServerFailure("Couldn't find the post 😱"));
    } on FormatException {
      return Left(ServerFailure("Bad format 👎"));
    }
  }
}

The main advantage of this approach comes from the client side where through the fold method we can and should capture success and failure. So we don't forget to validate both cases.

final service = NotesService()
final result = await service.getNotes();
result.fold((failure){
    // or do something with failure 
}, (notes){
    // do something with notes list
});

The Either class contains some useful methods that can help with this capture like isLeft or isRight for when necessary only validating failure or success.

Improving Either with freezed classes

So far we have something interesting. What if we use this same principle of being forced by the compiler to capture failure and success, improving our workflow, for our possible subset of Failure?

The idea is to eliminate this problem of adding if else to each subtype of Failure when we treat each failure separately.

final service = NotesService()
final result = await service.getNotes();
result.fold((failure){
    if(failure is NoInternetFailure){
        // do this
    }else if(failure is ServerFailure){
        // do this
    } else ...
}, (notes){
    // do something with notes list
});

We can solve this by adding a method similar to fold from Either to our Failure class, where regardless of Failure we can always handle all cases when necessary.

abstract class Failure {
  const Failure();

  void when({
    T Function() serverError,
    T Function() noInternetError,
  }) {
    final _this = this;
    if (_this is ServerFailure) {
      return serverError();
    } else if (_this is NoInternetError) {
      return noInternetError();
    }
    throw Exception('Failure $_this was not mapped');
  }

  String get message;

  factory Failure.serverError() => ServerFailure();
  factory Failure.noInternetError() => NoInternetError();
}

class ServerFailure extends Failure {
  @override
  String get message => 'Ops. An error ocurred.';
}

class NoInternetError extends Failure {
  @override
  String get message => 'Ops. Your connection seems off.';
}
final service = NotesService()
final result = await service.getNotes();
result.fold((failure){
    failure.when(
      noInternetError: () {},
      serverError: () {}),
}, (notes){
    // do something with notes list
});

This approach is interesting and we can still improve it using the package freezed that through code generation, generates all this boilerplate for us and with more functionality.

Using freezed our new Failure class would look like this.

@freezed
abstract class Failure with _$Failure{
  const factory Failure.serverError() = ServerFailure;
  const factory Failure.noInternetError() = NoInternetError;
}

And then we can use methods like: when, maybeWhen, map, maybeMap, to make our life easier.

// when
// Force treatment of all cases
failure.when(
      noInternetError: () {},
      serverError: () {}),
// maybeWhen
// It does not force all possibilities to be handled and allows to add a fallback for any unhandled case
failure.maybeWhen(
      // ignoramos o caso NoInternetError
      serverError: () {},
        orElse: (){},),
// map
// Forces treatment of all possibilities and allows transformation
final string = failure.map<String>(
      noInternetError: () => 'internet error :c',
      serverError: () => 'server error'),
// maybeMap
// Does not force treatment of all possibilities, allows fallback and transformation
final string = failure.maybeMap<String>(
      // ignoramos o caso NoInternetError
      serverError: () => 'server error',
        orElse: (){},),

Happy coding ❤️

References

https://resocoder.com/2019/12/11/proper-error-handling-in-flutter-dart-1-principles/

https://buildflutter.com/functional-programming-with-flutter/

https://medium.com/flutter-community/error-handling-in-flutter-98fce88a34f0