Guaranteeing Exhaustive State Handling in Dart 3 with Sealed Classes and Pattern Matching
Sealed classes in Dart 3 let you model finite state spaces with compile-time exhaustiveness checks. Learn how to define a sealed hierarchy, consume it with pattern-matching switches, and avoid silent logic gaps.
30 Sept 2026, 01:21 UTC

Why Exhaustiveness Matters
In many apps you model a component’s status as a set of discrete states – Loading, Success, Failure, etc. If you use ordinary classes, a switch over the state can silently ignore a new subtype added later. The compiler happily accepts the code, but at runtime the missing branch may trigger a default case or, worse, a NullError when you try to cast.
With Dart 3’s sealed modifier, the compiler knows the exact set of subclasses that can exist in the same library. When you write a switch or a pattern-matching expression over a sealed type, the analyzer will flag any missing case at compile time. This turns what used to be a silent logic gap into a hard build failure, making your state machine safer and easier to maintain.
Defining a Sealed State Machine
Below is a minimal example that demonstrates the pattern. All classes live in the same file (library) so the sealed contract is respected.
// lib/result.dart
sealed class Result<T> {}
class Success<T> extends Result<T> {
final T value;
Success(this.value);
}
class Failure extends Result<void> {
final String message;
Failure(this.message);
}
class Loading extends Result<void> {}
// A factory that decides which subtype to create.
Result<T> fetchData<T>(Future<T> Function() fetch) async {
try {
final data = await fetch();
return Success(data);
} catch (e) {
return Failure(e.toString());
}
}
Consuming the Sealed Type
When you switch on Result, the analyzer forces you to handle every subtype:
Result<String> result = await fetchData(() async => 'Hello');
final message = switch (result) {
Success(value: final v) => 'Success: $v',
Failure(message: final m) => 'Error: $m',
Loading() => 'Loading…',
};
print(message);
Notice the pattern-matching syntax: Success(value: final v) extracts the value field, while Loading() matches the zero-argument constructor. If you omit Loading(), the analyzer will emit an error similar to Missing case for Loading in switch expression.
Practical Checklist
- Verify SDK Version – Sealed classes are only available in Dart 3.0+. Run
dart --versionor checkenvironment.sdkinpubspec.yaml.dart --version # Expected output: Dart SDK version: 3.x.x - Keep Subclasses in the Same Library – The sealed contract applies only to direct subclasses in the same file or library. If you need to expose the hierarchy to other packages, consider using
baseorfinalinstead. - Use Pattern Matching Contexts – Exhaustiveness checks apply to
switchexpressions,switchstatements, andif-caseconstructs. Legacyif (x is Type)chains will not trigger a compile-time error. - Factory Constructors – Since you can’t instantiate a sealed class directly, expose factory functions (as shown in
fetchData) to create the appropriate subtype.
Trade-Offs & Limitations
- Library-Bound Subtyping – Because subclasses must live in the same library, you can’t extend a sealed type from another package. This improves safety but limits modularity.
- Pattern Matching Only – The compiler’s exhaustiveness check is only active in pattern-matching contexts. If you rely on
if (x is Type)checks, you’ll still have to manually guard against missing cases. - No Runtime Reflection – Since the hierarchy is known at compile time, you can’t use runtime type discovery (e.g.,
runtimeType) to add new subtypes dynamically.
Actionable Next Steps
- Upgrade to Dart 3 if you haven’t already.
- Refactor existing state enums or base classes into sealed hierarchies, placing all related subclasses in the same library.
- Replace any
if (x is …)chains with pattern-matchingswitchexpressions to benefit from compile-time exhaustiveness checks. - Run
dart analyzeafter each change to catch any unhandled cases before they reach production.
By embracing sealed classes and pattern matching, you turn state-handling bugs into compiler errors, giving you a more robust and maintainable codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.