Dart Failure Handling

Dart Failure Handling

I write these notes for myself so I don't forget the more complicated stuff I come across.

In a great video series, Matt from ResoCoder shows how capturing exceptions at the repository and returning failures to the layers above it a better approach than trying to capture exceptions everywhere in your code.  This is a summary.

Using techniques borrowed from functional programming languages, lower layers return an either object, containing left and right values.  Conventionally,  left values represent failures and right values represent successes.

When used this way, elements that depend on the results of these operations must account for all possible combinations in both types.

Imagine we have a class of UserFailure that describes all the ways that a sign in attempt can fail.


abstract class UserFailure with _$UserFailure {
  const factory UserFailure.unexpected(String message) = Unexpected;
  const factory UserFailure.insufficientPermissions() = InsufficientPermissions;
  const factory UserFailure.unableToUpdate() = UnableToUpdate;
    const factory UserFailure.cancelledByUser() = CancelledByUser;
  const factory UserFailure.serverError() = ServerError;
  const factory UserFailure.identityServerError() = IdentityServerError;
  const factory UserFailure.emailAlreadyInUse() = EmailAlreadyInUse;
  const factory UserFailure.invalidEmailAndPasswordCombination() =
      InvalidEmailAndPasswordCombination;
  const factory UserFailure.userNotFound() = UserNotFound;
  const factory UserFailure.localUserNotFound() = LocalUserNotFound;
  const factory UserFailure.userDisabled() = UserDisabled;
  const factory UserFailure.weakPassword() = WeakPassword;
  const factory UserFailure.userNotLoggedIn() = UserNotLoggedIn;
  const factory UserFailure.authenticationServerError() =
      AuthenticationServerError;
}

In a presentation layer, for example, we can make a call to a repository like so:

Future<Either<UserFailure, User>> user = await _repository.getUser(String id);

What will be returned is an either.  The result contains "either" an AuthFailure or a User.

The calling code can then look like this:

either.fold(
(failure) {
    final message = failure.maybeMap(
        cancelledByUser: (_) => 'Cancelled',
        serverError: (_) => 'Server error',
        emailAlreadyInUse: (_) => 'Email already in use',
        invalidEmailAndPasswordCombination: (_) =>
        'Invalid email and password combination',
        authenticationServerError: (_) =>
        'Authentication server not online',
        identityServerError: (value) => '${value.toString()}',
        orElse: () => 'i have no idea what happend',
        );
	print(messsage);
    },
(success) { 
    	// do something cool with the user that was returned.
    },
);

The fold statement won't allow the statement to compile unless both the failure value and success values have been dealt with.

If we had just used the map statement, then the compiler would make us deal with each possible case of UserFailure that existed.

However, since we used the failure.maybeMap statement, the compiler will allow us to use an orElse statement to catch any we didn't collect. Regardless, the compiler is forcing us to make a decision on what happens to anything we don't list.

From a practical standpoint, the user doesn't need to know about the 1/2 dozen network problems that thwarted their network call; they just need to know it didn't work.  

Using this technique allows the data source layer to throw whatever exceptions it need to.  The repository layer then selects the ones that need to be communicated to the rest of the app and turns them into failures.

Now the presentation layer only has to deal with the ones it needs to and using the technique above it's forced to deal with all of the options.  This goes a long way to squashing unexpected errors and crashes.

But...

What happens if you want to catch two types of failures at the same time.

As it turns out, DART doesn't support class inheritance.  That means that certain techniques no longer work as they would in C# for example.  The results have to be composed and that means it can a bit messy.

Following on Matt's advice, it's possible to create a composed class of failures.

Here's what that looks like:

We create three failure classes; one is nested and the other two are more specific, in this case a ValueFailure and a FeatureValueFailure .

@freezed
abstract class NestedFailure<T> with _$NestedFailure<T> {
  const factory NestedFailure.feature(FeatureValueFailure<T> f) = _FeatureValueFailure<T>;
  const factory NestedFailure.valueFailure(ValueFailure<T> f) =
      _ValueFailure<T>;
}

@freezed
abstract class ValueFailure<T> with _$ValueFailure<T> {
  const factory ValueFailure.exceedingLength({
    @required T failedValue,
    @required int max,
  }) = ExceedingLength<T>;
  const factory ValueFailure.empty({
    @required T failedValue,
  }) = Empty<T>;
  
  const factory ValueFailure.invalidEmail({
    @required T failedValue,
  }) = InvalidEmail<T>;
}

@freezed
abstract class FeatureValueFailure<T> with _$FeatureValueFailure<T> {
  const factory FeatureValueFailure.somethingMissing({
    @required T someThing,
  }) = SomethingMissing<T>;  const factory FeatureValueFailure.somethingNotThere({
    @required T someThing,
  }) = SomethingNotThere<T>;
}

The NestedFailure can return either type of failure in the same result.

To process the result, the repository code looks a little like this:

  @override
  Either<NestedFailure, User> testingNested() {
    final int selection = 0;
    switch (selection) {
      case 0:
        return left(const NestedFailure.feature(
            FeatureValueFailure.somethingMissing(someThing: 'some ting, man')));
        break;

      case 1:
        return left(const NestedFailure.valueFailure(
            ValueFailure.empty(failedValue: 'you be empty')));
        break;
    }
    return right(const User(name: 'asdf'));
  }

And the authentication bloc looks like this:

        final blah = _userRepository.testingNested();
        yield blah.fold(
            (nestedFailure) => nestedFailure.map(
                feature: (feature) => feature.f.map(
                      somethingMissing: (_) =>
                          const AuthState.unauthenticated(),
                      somethingNotThere: (_) =>
                          const AuthState.unauthenticated(),
                    ),
                valueFailure: (vf) => const AuthState.unauthenticated()),
            (r) => AuthState.authenticatedMerchantUser(r));

What you can see is the blah result returns an either.  On the left is a  NestedFailure which is mapped onto all the possible types it could be, which including both a FeatureValueFailure and a ValueFailure.

As it turns out, the FeatureValueFailure then gets mapped onto all of it's possible values, include SomethingMissing and SomethingNotThere.

In my shoddy example, I just return the same error everywhere, but instead, all kinds of different actions could result depending on each type of failure within each type of nested failure.

There, now when I get completely confused, I can just refer to this and become even more confused.