Dart Sealed Classes and Pattern Matching for Exhaustive State Handling
Why sealed classes matter
When I started building Flutter apps, I often represented UI states with a plain enum or a handful of subclasses. The problem? The compiler could not guarantee that I handled every case. A missing branch would only surface at runtime, usually as a blank screen or a crash in production.
Dart 3 introduced sealed classes together with pattern matching. A sealed class defines a closed hierarchy: only the declared subclasses can extend it, and the compiler knows the complete set. Pair that with a switch expression and you get exhaustiveness checking for free.
Sealed classes turn "forgot a case" into a compile‑time error, not a user‑visible bug.
Modeling a fetch state
Consider a typical data‑fetch flow: idle, loading, success with a payload, or failure with an error message. With sealed classes the model looks like this:
sealed class FetchState {
const FetchState();
}
class Idle extends FetchState {
const Idle();
}
class Loading extends FetchState {
const Loading();
}
class Success extends FetchState {
final T data;
const Success(this.data);
}
class Failure extends FetchState {
final String message;
const Failure(this.message);
}
Notice the generic T. The same hierarchy works for any payload type — user profile, product list, or a simple string.
Pattern matching in a switch expression
Now the UI layer can react to the state with a single switch expression. Because the hierarchy is sealed, the compiler warns if a subclass is missing.
Widget buildView(FetchState state) {
return switch (state) {
Idle() => const Center(child: Text('Pull to refresh')),
Loading() => const Center(child: CircularProgressIndicator()),
Success(data: var profile) => ProfileCard(profile: profile),
Failure(message: var msg) => Center(child: Text('Error: $msg')),
};
}
The data: and message: patterns bind the inner fields directly to local variables. No casting, no as, no null checks.
Exhaustiveness checking in practice
Add a new subclass — say Unauthorized — and the IDE immediately flags the switch as non‑exhaustive. You fix it in one place, and the whole codebase stays consistent.
class Unauthorized extends FetchState {
const Unauthorized();
}
Now the switch must include a case for Unauthorized(). If you forget, the build fails. This is a huge win for teams that ship frequently; the type system becomes a safety net rather than a suggestion.
Extending the pattern
- Combine with Riverpod or Bloc: The state objects can be emitted directly from a notifier. The UI stays pure and testable.
- Serialize to JSON: Because each subclass is a distinct type, you can write a single
toJsonextension that pattern‑matches on the sealed hierarchy. - Testing: Unit tests can assert on exact subtypes without stringly‑typed enums.
Takeaways
Sealed classes + pattern matching give you:
- Closed hierarchies the compiler understands.
- Declarative, exhaustive switch expressions.
- Zero‑boilerplate field extraction.
Next time you reach for an enum or a base class with many subclasses, ask yourself whether the set of states is truly fixed. If it is, seal it. Your future self — and your CI pipeline — will thank you.