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.