Handling Async States in Dart 3 with Sealed Classes and Exhaustive Switching
Learn how to eliminate impossible states in Dart 3 by using sealed classes and exhaustive switch expressions to model async loading, success, and error states.
11 Aug 2025, 20:55 UTC

The Problem: Impossible States in Async Operations
Managing the state of an asynchronous request—such as a network call—often leads to "impossible states." A common anti-pattern is using a combination of a boolean isLoading, a nullable data object, and a nullable errorMessage. This allows for invalid configurations, such as isLoading being true while errorMessage is also populated, forcing developers to write defensive null-checks and complex if/else logic.
The solution is to model these states as a sealed class hierarchy. By using a sealed class, you define a closed set of possible states. When combined with Dart 3's switch expressions, the compiler enforces that every possible state is handled, turning runtime logic errors into compile-time requirements.
Prerequisites
- Dart SDK 3.0+: Ensure your
pubspec.yamlenvironment constraint is set tosdk: '>=3.0.0 <4.0.0'. - Library Scope: All subclasses of a sealed class must be defined within the same library (the same file or connected via
partandpart ofdirectives).
Implementation Procedure
1. Define the Sealed State Hierarchy
Declare a sealed class as the base. A sealed class is implicitly abstract and cannot be instantiated. Define the specific states as subclasses. Use final for leaf classes to prevent further unexpected inheritance.
sealed class AsyncState {}
final class Loading extends AsyncState {}
final class Success extends AsyncState {
final T data;
Success(this.data);
}
final class Failure extends AsyncState {
final Exception error;
Failure(this.error);
}
2. Consume States with Exhaustive Switching
Use a switch expression to map the state to a UI element or a logic flow. Because the base class is sealed, the Dart analyzer knows exactly which subtypes exist.
String getMessage(AsyncState state) {
return switch (state) {
Loading() => 'Fetching data...',
Success(data: var value) => 'Received: $value',
Failure(error: var e) => 'Error occurred: ${e.toString()}',
};
}
Note that Success(data: var value) uses pattern destructuring to extract the inner data directly within the case, removing the need for manual casting.
Verification and Analyzer Checks
The primary benefit of this pattern is the exhaustiveness check. You can verify this behavior using the following steps:
- Run
dart analyzein your terminal. - Intentionally remove one case (e.g., the
Failurecase) from your switch expression. - The analyzer will flag an error: "The type 'AsyncState' is not exhaustively matched by the switch expression."
- Add a new state to the hierarchy (e.g.,
final class Empty extends AsyncState {}). - Observe that every switch expression using
AsyncStateacross your entire project is now flagged as an error until theEmptycase is handled.
Comparison: Sealed Classes vs. Enums
| Feature | Enum + Separate Fields | Sealed Classes |
|---|---|---|
| Payloads | External variables (nullable) | Integrated into the subtype |
| Type Safety | Manual casting/null checks | Strongly typed via destructuring |
| Exhaustiveness | Supported for enums | Supported for the entire hierarchy |
| State Validity | Possible to have conflicting states | Only one state can exist at a time |
Limitations and Recovery
Library Constraints: If your state hierarchy becomes too large for one file, use the part and part of keywords. This allows you to split the sealed class and its subtypes across multiple files while remaining in the same logical library.
Opting Out of Strictness: In rare cases where a switch should ignore new states without breaking the build, you can add a wildcard case _. Warning: This disables the exhaustiveness check for that specific block, returning you to the risk of unhandled states.
return switch (state) {
Success() => 'Done',
_ => 'Something else happened', // Disables exhaustiveness check
};
Rollback Strategy
If you must support a Dart SDK version older than 3.0, you cannot use sealed. To roll back, replace the sealed modifier with abstract and replace switch expressions with standard if (state is Success) type-checking blocks. Note that you will lose the compile-time exhaustiveness guarantee.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.