Error handling with Result objects
Dart provides a built-in error handling mechanism with the ability to throw and catch exceptions.
As mentioned in the Error handling documentation, Dart's exceptions are unhandled exceptions. This means that methods that throw exceptions don’t need to declare them, and calling methods aren't required to catch them either.
This can lead to situations where exceptions are not handled properly. In large projects, developers might forget to catch exceptions, and the different application layers and components could throw exceptions that aren’t documented. This can lead to errors and crashes.
In this guide, you will learn about this limitation and how to mitigate it using the result pattern.
Error flow in Flutter applications
#Applications following the Flutter architecture guidelines are usually composed of view models, repositories, and services, among other parts. When a function in one of these components fails, it should communicate the error to the calling component.
Typically, that's done with exceptions. For example, an API client service failing to communicate with the remote server might throw an HTTP Error Exception. The calling component, for example a Repository, would have to either capture this exception or ignore it and let the calling view model handle it.
This can be observed in the following example. Consider these classes:
- A service,
ApiClientService, performs API calls to a remote service. - A repository,
UserProfileRepository, provides theUserProfileprovided by theApiClientService. - A view model,
UserProfileViewModel, uses theUserProfileRepository.
The ApiClientService contains a method, getUserProfile,
that throws exceptions in certain situations:
- The method throws an
HttpExceptionif the response code isn’t 200. - The JSON parsing method throws an exception if the response isn't formatted correctly.
- The HTTP client might throw an exception due to networking issues.
The following code tests for a variety of possible exceptions:
class ApiClientService {
// ···
Future<UserProfile> getUserProfile() async {
try {
final request = await client.get(_host, _port, '/user');
final response = await request.close();
if (response.statusCode == 200) {
final stringData = await response.transform(utf8.decoder).join();
return UserProfile.fromJson(jsonDecode(stringData));
} else {
throw const HttpException('Invalid response');
}
} finally {
client.close();
}
}
}The UserProfileRepository doesn’t need to handle
the exceptions from the ApiClientService.
In this example, it just returns the value from the API Client.
class UserProfileRepository {
// ···
Future<UserProfile> getUserProfile() async {
return await _apiClientService.getUserProfile();
}
}Finally, the UserProfileViewModel
should capture all exceptions and handle the errors.
This can be done by wrapping
the call to the UserProfileRepository with a try-catch:
class UserProfileViewModel extends ChangeNotifier {
// ···
Future<void> load() async {
try {
_userProfile = await userProfileRepository.getUserProfile();
notifyListeners();
} on Exception catch (exception) {
// handle exception
}
}
}In reality, a developer might forget to properly capture exceptions and end up with the following code. It compiles and runs, but crashes if one of the exceptions mentioned previously occurs:
class UserProfileViewModel extends ChangeNotifier {
// ···
Future<void> load() async {
_userProfile = await userProfileRepository.getUserProfile();
notifyListeners();
}
}You can attempt to solve this by documenting the ApiClientService,
warning about the possible exceptions it might throw.
However, since the view model doesn’t use the service directly,
other developers working in the codebase might miss this information.
Using the result pattern
#An alternative to throwing exceptions
is to wrap the function output in a Result object.
When the function runs successfully,
the Result contains the returned value.
However, if the function does not complete successfully,
the Result object contains the error.
A Result is a sealed class
that can either subclass Ok or the Error class.
Return the successful value with the subclass Ok,
and the captured error with the subclass Error.
The following code shows a sample Result class that
has been simplified for demo purposes.
A full implementation is at the end of this page.
/// Utility class that simplifies handling errors.
///
/// Return a [Result] from a function to indicate success or failure.
///
/// A [Result] is either an [Ok] with a value of type [T]
/// or an [Error] with an [Exception].
///
/// Use [Result.ok] to create a successful result with a value of type [T].
/// Use [Result.error] to create an error result with an [Exception].
sealed class Result<T> {
const Result();
/// Creates an instance of Result containing a value
factory Result.ok(T value) => Ok(value);
/// Create an instance of Result containing an error
factory Result.error(Exception error) => Error(error);
}
/// Subclass of Result for values
final class Ok<T> extends Result<T> {
const Ok(this.value);
/// Returned value in result
final T value;
}
/// Subclass of Result for errors
final class Error<T> extends Result<T> {
const Error(this.error);
/// Returned error in result
final Exception error;
}In this example,
the Result class uses a generic type T to represent any return value,
which can be a primitive Dart type like String or an int or a custom class like UserProfile.
Creating a Result object
#For functions using the Result class to return values,
instead of a value,
the function returns a Result object containing the value.
For example, in the ApiClientService,
getUserProfile is changed to return a Result:
class ApiClientService {
// ···
Future<Result<UserProfile>> getUserProfile() async {
// ···
}
}Instead of returning the UserProfile directly,
it returns a Result object containing a UserProfile.
To facilitate using the Result class,
it contains two named constructors, Result.ok and Result.error.
Use them to construct the Result depending on desired output.
As well, capture any exceptions thrown by the code
and wrap them into the Result object.
For example, here the getUserProfile() method
has been changed to use the Result class:
class ApiClientService {
// ···
Future<Result<UserProfile>> getUserProfile() async {
try {
final request = await client.get(_host, _port, '/user');
final response = await request.close();
if (response.statusCode == 200) {
final stringData = await response.transform(utf8.decoder).join();
return Result.ok(UserProfile.fromJson(jsonDecode(stringData)));
} else {
return const Result.error(HttpException('Invalid response'));
}
} on Exception catch (exception) {
return Result.error(exception);
} finally {
client.close();
}
}
}The original return statement was replaced
with a statement that returns the value using Result.ok.
The throw HttpException()
was replaced with a statement that returns Result.error(HttpException()),
wrapping the error into a Result.
As well, the method is wrapped with a try-catch block
to capture any exceptions thrown by the Http client
or the JSON parser into a Result.error.
The repository class also needs to be modified,
and instead of returning a UserProfile directly,
now it returns a Result<UserProfile>.
Future<Result<UserProfile>> getUserProfile() async {
return await _apiClientService.getUserProfile();
}Unwrapping the Result object
#Now the view model doesn't receive the UserProfile directly,
but instead it receives a Result containing a UserProfile.
This forces the developer implementing the view model
to unwrap the Result to obtain the UserProfile,
and avoids having uncaught exceptions.
class UserProfileViewModel extends ChangeNotifier {
// ···
UserProfile? userProfile;
Exception? error;
Future<void> load() async {
final result = await userProfileRepository.getUserProfile();
switch (result) {
case Ok<UserProfile>():
userProfile = result.value;
case Error<UserProfile>():
error = result.error;
}
notifyListeners();
}
}The Result class is implemented using a sealed class,
meaning it can only be of type Ok or Error.
This allows the code to evaluate the result with a
switch result or expression.
In the Ok<UserProfile> case,
obtain the value using the value property.
In the Error<UserProfile> case,
obtain the error object using the error property.
Improving control flow
#Wrapping code in a try-catch block ensures that
thrown exceptions are caught and not propagated to other parts of the code.
Consider the following code.
class UserProfileRepository {
// ···
Future<UserProfile> getUserProfile() async {
try {
return await _apiClientService.getUserProfile();
} catch (e) {
try {
return await _databaseService.createTemporaryUser();
} catch (e) {
throw Exception('Failed to get user profile');
}
}
}
}In this method, the UserProfileRepository
attempts to obtain the UserProfile
using the ApiClientService.
If it fails, it tries to create a temporary user in a DatabaseService.
Because either service method can fail, the code must catch the exceptions in both cases.
This can be improved using the Result pattern:
Future<Result<UserProfile>> getUserProfile() async {
final apiResult = await _apiClientService.getUserProfile();
if (apiResult is Ok) {
return apiResult;
}
final databaseResult = await _databaseService.createTemporaryUser();
if (databaseResult is Ok) {
return databaseResult;
}
return Result.error(Exception('Failed to get user profile'));
}In this code, if the Result object is an Ok instance,
then the function returns that object;
otherwise, it returns Result.Error.
Putting it all together
#In this guide, you have learned
how to use a Result class to return result values.
The key takeaways are:
Resultclasses force the calling method to check for errors, reducing the amount of bugs caused by uncaught exceptions.Resultclasses help improve control flow compared to try-catch blocks.Resultclasses aresealedand can only returnOkorErrorinstances, allowing the code to unwrap them with a switch statement.
Below you can find the full Result class
as implemented in the Compass App example
for the Flutter architecture guidelines.
/// Utility class that simplifies handling errors.
///
/// Return a [Result] from a function to indicate success or failure.
///
/// A [Result] is either an [Ok] with a value of type [T]
/// or an [Error] with an [Exception].
///
/// Use [Result.ok] to create a successful result with a value of type [T].
/// Use [Result.error] to create an error result with an [Exception].
///
/// Evaluate the result using a switch statement:
/// ```dart
/// switch (result) {
/// case Ok(): {
/// print(result.value);
/// }
/// case Error(): {
/// print(result.error);
/// }
/// }
/// ```
sealed class Result<T> {
const Result();
/// Creates a successful [Result], completed with the specified [value].
const factory Result.ok(T value) = Ok._;
/// Creates an error [Result], completed with the specified [error].
const factory Result.error(Exception error) = Error._;
}
/// A successful [Result] with a returned [value].
final class Ok<T> extends Result<T> {
const Ok._(this.value);
/// The returned value of this result.
final T value;
@override
String toString() => 'Result<$T>.ok($value)';
}
/// An error [Result] with a resulting [error].
final class Error<T> extends Result<T> {
const Error._(this.error);
/// The resulting error of this result.
final Exception error;
@override
String toString() => 'Result<$T>.error($error)';
}除非另有说明,本文档之所提及适用于 Flutter 的最新稳定版本,本页面最后更新时间: 2025-10-02。 查看文档源码 或者 为本页面内容提出建议.