FutureResult in Flutter: Explicit Control Flow and Practical Error Handling
1. Introduction
Flutter applications often need to communicate the outcome of an asynchronous operation between several architectural layers. A service may communicate with a remote API, a repository may coordinate the service, and a view model or UI component may finally react to the result.
Dart commonly communicates failures by throwing exceptions. Exceptions are useful, but they are not visible in a method's return type. A method can throw an exception without requiring its caller to handle it explicitly.
The Result pattern provides another approach. Instead of communicating success through the return value and failure through an exception, both outcomes are represented by one result type. The caller must inspect the result before continuing.
Flutter's architecture guidance describes this approach as a way to make failure explicit and improve control flow:
Flutter Result pattern: Improving control flow
FutureResult<T> applies this idea to asynchronous operations. A successful operation returns a value, while a failed operation returns an error result.
This article discusses FutureResult in general and focuses on one particular design question:
Should
FutureResult.errorreturn an exception object, or can an application-defined string be the correct error contract?
The answer depends on the responsibility of the result. A result that must transport every diagnostic detail from the original exception needs a richer error representation. A result that communicates the application-level outcome needed by the next layer can intentionally use a string.
2. FutureResult Makes Failure Part of the Return Contract
Consider a service that throws an exception when it cannot communicate with a remote server:
A repository may simply forward the call:
The caller must know that the operation can throw an exception:
This code can be valid. However, the possibility of failure is not visible in the return type. A developer can call the method without realizing that an exception must be handled.
With FutureResult, success and failure use the same explicit return contract:
The caller handles the result explicitly:
The failure is now part of the declared application flow. The caller does not need to know which implementation exception was thrown by the service.
Note: An exception-based API can theoretically document its failure contract separately from the return type. In practice, however, such contracts are often missing or overlooked. Developers and AI tools may then reproduce the common pattern of surrounding large sections of a function with broad try-catch blocks, hiding the distinction between expected failures and unexpected programming errors. An explicit error contract — such as FutureResult or another clearly defined result type — does not eliminate programming errors, but it makes expected failures visible to the caller and reduces the risk of this pattern.
3. The Error-String Question
A common criticism of FutureResult.error(String) is that converting an exception directly to a string loses information from the FutureResult value. The original exception type, stack trace, status code, and other diagnostic details are no longer available through the FutureResult value itself.
That criticism is valid when arbitrary exception text is used as the application contract:
The caller cannot reliably make decisions based on arbitrary exception text. The text may change when a dependency changes, and different exceptions may produce different messages for the same application-level problem.
However, this is different from returning an application-defined error value:
The networking or service layer can translate a technical exception into one of these application-defined values:
The caller can then make a reliable decision:
In this example, the string is not uncontrolled diagnostic output. It is a stable value defined in the application context. The service translates a technical failure, such as SocketException, into the application-level outcome that the caller understands.
The application does not need to expose the networking library's exception types to every layer. It also does not need to create a new error class for every expected failure when a small set of predefined values is sufficient.
The relevant distinction is therefore:
- Arbitrary exception text: implementation output that is unsuitable for reliable control flow.
- Application-defined error string: a stable value used as part of the contract between application layers.
4. Application Errors and Diagnostic Information
The fact that diagnostic information is not transported through FutureResult does not mean that it must be discarded. The service or repository can log the original exception and stack trace before converting the failure into the application-defined error value.
The FutureResult then transports the application outcome, while the logging or crash-reporting system receives the technical details.
This separates two different responsibilities:
- Application result: communicates what the next application layer should do.
- Diagnostic information: helps developers investigate why the failure occurred.
A UI component does not normally need to know whether the connection failed because of DNS, a refused socket, or another operating-system-level cause. A logging system may need exactly that information. Both requirements can be fulfilled without placing all diagnostic details into the application result.
5. Error Values and Display Messages
In a simple application, the same application-defined string can be used for control flow and user presentation:
This keeps the implementation small and avoids introducing an additional error type when the application does not need one.
However, the application-defined value and the displayed message have different responsibilities. The value is used by application logic, while the message is intended for a person.
If the application later requires localization or several different messages for the same error, the value can become a stable error identifier:
The presentation layer can then translate the identifier into the appropriate message:
An application-defined error string must remain stable while it is used for control flow. Changing the value changes the contract between the layer that creates the result and the layer that consumes it.
6. FutureResult and Uncaught Errors
FutureResult is intended for errors that are part of the normal application flow. It allows an operation to explicitly return either a successful result or an application-level error.
Not every error, however, belongs in a FutureResult. Unexpected exceptions and uncaught Flutter errors can be handled at the application's top level via runZonedGuarded.
This keeps the two mechanisms separate:
FutureResultis used to explicitly communicate application-level errors.FlutterError.onErrorcaptures uncaught Flutter framework errors.runZonedGuardedcaptures uncaught errors escaping the application zone.- Technical exceptions and stack traces remain available for logging and diagnostics.
This means that unexpected failures do not need to be converted into FutureResult.error(...). They can propagate to the application's top-level error handling instead.
7. When a String Is Not Enough
A single error string is suitable when the operation has one meaningful application-level failure outcome. It may not be sufficient when the caller needs structured data, several field-specific validation messages, retry metadata, status codes, or detailed domain information.
For example, a form validation operation may need to return several errors at the same time:
An API operation may also need to distinguish several failures that have the same display message but different control-flow consequences. In such cases, a richer result or a separate domain error model may be appropriate.
More information is not automatically better, however. A richer error representation adds types, fields, constructors, mapping logic, and decisions about which layers should understand each field.
The appropriate error representation is the smallest contract that provides the information required by the application.
8. Conclusion
FutureResult makes success and failure explicit in the return contract. This improves control flow because callers must handle the result instead of relying only on an exception that may be thrown somewhere inside the called operation.
FutureResult.error(String) does not automatically represent a loss of useful information. The result is intentionally limited when the application only needs a stable, predefined error value for control flow and presentation.
The important distinction is between arbitrary exception text and an application-defined error string. Arbitrary text is not a reliable contract. An application-defined value can be a reliable contract when it is shared by the producer and consumer of the result.
Diagnostic information can be logged or reported before the technical exception is converted into the application-level result. It does not necessarily need to be transported through every application layer.
The purpose of this approach is not to claim that strings are the best representation for every error. A richer error type is appropriate when the application needs structured data, multiple messages, retry metadata, status codes, or detailed domain information.
The purpose is to use the smallest error contract that provides the information required by the application. When a small set of stable, predefined outcomes is sufficient, FutureResult.error(String) keeps the code explicit without adding unnecessary complexity.