Suppose a profile screen loads a user:
Future<User> getUser(String id);
The success type is clear, but the failure behavior is not. The method might throw when the network is unavailable, return null for a missing user, or fail while parsing the response.
This uncertainty usually leads to broad try-catch blocks in the UI:
try {
final user = await repository.getUser(id);
// Show the profile
} catch (_) {
// Show "Something went wrong"
}
The user gets the same message for every problem, and developers lose useful context.
Decide which failures are expected#
Expected failures are normal outcomes that the app knows how to handle:
- the user is offline;
- a record does not exist;
- authentication has expired;
- a coupon is invalid;
- a payment is rejected.
Programming mistakes and broken assumptions are different. An unexpected null value or an invalid internal state should still be visible during development and monitoring. Do not turn every thrown error into a friendly network message.
Give expected failures names#
Create a small set of failures for the feature:
sealed class UserFailure {
const UserFailure();
}
final class UserNotFound extends UserFailure {
const UserNotFound();
}
final class UserOffline extends UserFailure {
const UserOffline();
}
final class UserServiceUnavailable extends UserFailure {
const UserServiceUnavailable();
}
These names are more useful than strings such as "404" or "SocketException". They describe what the application needs to know.
Return either a failure or a user#
Using an Either-style result makes both outcomes visible in the method signature:
Future<Either<UserFailure, User>> getUser(String id);
By convention:
- Left contains the failure.
- Right contains the successful value.
The repository converts API-specific exceptions into feature failures:
Future<Either<UserFailure, User>> getUser(String id) async {
try {
final response = await api.getUser(id);
return Right(User.fromJson(response.data));
} on NotFoundException {
return const Left(UserNotFound());
} on NoConnectionException {
return const Left(UserOffline());
} on ServerException {
return const Left(UserServiceUnavailable());
}
}
The catches are intentionally specific. An unrelated programming error should not be reported as an offline state.
Handle every result in the state layer#
The state controller can turn the result into UI state:
Future<void> loadUser(String id) async {
state = const ProfileState.loading();
final result = await repository.getUser(id);
state = result.fold(
(failure) => ProfileState.failure(failure),
(user) => ProfileState.loaded(user),
);
}
The widget now renders a clear set of states:
return switch (state) {
ProfileLoading() => const CircularProgressIndicator(),
ProfileLoaded(:final user) => ProfileDetails(user: user),
ProfileFailure(:final failure) => ErrorView(
message: messageFor(failure),
onRetry: () => loadUser(userId),
),
};
Dart's patterns and sealed classes help the analyzer check that every state is handled.
Show messages based on what the user can do#
Keep user-facing text out of the repository:
String messageFor(UserFailure failure) {
return switch (failure) {
UserNotFound() => 'This profile no longer exists.',
UserOffline() => 'Check your connection and try again.',
UserServiceUnavailable() =>
'Profiles are temporarily unavailable. Please try again.',
};
}
The repository reports the problem. The presentation layer decides how to explain it.
This also makes actions easier to choose. An offline state may show Retry. An expired session may navigate to sign-in. A missing record may return to the previous screen.
Use map for simple success transformations#
If you only need to transform the successful value, map avoids manually handling the failure again:
final displayNameResult = userResult.map(
(user) => user.fullName,
);
If userResult is a failure, it stays a failure. If it contains a user, it becomes a successful string.
For multiple operations that can fail, flatMap can stop at the first failure:
final result = validateCart(cart)
.flatMap(reserveItems)
.flatMap(createOrder);
Use this when it makes the steps easier to read. A normal async function is still fine when chaining makes the code less obvious.
Do not use Either everywhere#
Choose the simplest return type that explains the outcome:
| Situation | Useful return type |
|---|---|
| A value is always expected | User |
| A value may simply be absent | User? |
| Failure details affect behavior | Either<Failure, User> |
| A programming assumption is broken | Throw an exception |
A map lookup usually does not need a custom failure hierarchy. A payment operation probably does.
Test failures like normal values#
Typed results make failure tests direct:
test('returns UserOffline when the request has no connection', () async {
api.throwOnRequest(const NoConnectionException());
final result = await repository.getUser('42');
expect(result, const Left<UserFailure, User>(UserOffline()));
});
You can separately test that UserOffline becomes the correct message and retry action.
Using Fabrik for this#
fabrik_result provides Either, Option, and Unit for Dart projects, including common operations such as fold, map, and flatMap.
The important part is not the name of the result type. It is making expected failures visible in the method signature so the caller can handle them deliberately.