Dart Sealed Classes: Exhaustive Switches and Controlled Hierarchies
A practical guide to Dart sealed classes and class modifiers: compiler-checked exhaustive switches, base, interface, final, and when to reach for each one.
Published on • September 25, 2026
AI Assistant

Every Flutter developer eventually builds a state machine: loading, loaded, error. The classic approach is an enum plus a status field, or an abstract class with a handful of subclasses. Both work — until someone adds a new state and forgets to handle it in one of the fifteen switches scattered across the codebase.
Dart 3 introduced sealed classes (alongside base, interface, and final) to make that category of bug impossible. A sealed class gives the compiler a complete, enumerable list of subtypes, so it can statically verify that every switch over those subtypes handles every case. Miss one, and your app does not build.
This post covers how sealed classes work, how they relate to the other class modifiers, and how to use them in real Flutter applications.
What sealed Means
A sealed class cannot be extended or implemented outside of the library where it is declared. All direct subtypes must live in the same library. In exchange for that restriction, the compiler knows every possible subtype and can enforce exhaustive switches:
sealed class Vehicle {}
class Car extends Vehicle {}
class Truck implements Vehicle {}
class Bicycle extends Vehicle {}
void main() {
Vehicle myCar = Car(); // OK - subclasses are not abstract
String sound = switch (myCar) {
Car() => 'vroom',
Truck() => 'VROOOOMM',
// Compile error: 'Bicycle' is missing.
// The switch is not exhaustive, so this code does not build.
};
}
Two details are worth calling out:
- Sealed classes are implicitly abstract. You can never construct a
Vehicledirectly, but you can declare factory constructors and constructors that subclasses reuse. - Subclasses of a sealed class are not implicitly abstract.
Carabove can be instantiated freely.
The exhaustiveness check works in switch statements and switch expressions, and it interacts cleanly with Dart 3 pattern matching — object patterns like Car() destructure the subtype while the compiler verifies the full set.
Why Exhaustiveness Matters in Flutter Apps
Consider a typical async state for a details screen:
sealed class UserDetails {
const UserDetails();
}
class UserDetailsLoading extends UserDetails {}
class UserDetailsLoaded extends UserDetails {
const UserDetailsLoaded({required this.user});
final User user;
}
class UserDetailsError extends UserDetails {
const UserDetailsError({required this.message});
final String message;
}
A widget renders it with a single switch:
Widget build(BuildContext context) {
return switch (state) {
UserDetailsLoading() => const CircularProgressIndicator(),
UserDetailsLoaded(:final user) => ProfileView(user: user),
UserDetailsError(:final message) => ErrorRetry(message: message),
};
}
Now suppose a teammate adds UserDetailsOffline for airplane mode. Every switch in the app that misses the new case fails to compile. The compiler becomes a test suite for your state machine’s completeness — no default case silently swallowing the new state, no runtime null render.
This is why sealed classes pair naturally with Bloc (event and state classes), Riverpod (state unions, often via Freezed’s generated unions), and result types:
sealed class Result<T> {
const Result();
}
class Ok<T> extends Result<T> {
const Ok(this.value);
final T value;
}
class Err<T> extends Result<T> {
const Err(this.error);
final Object error;
}
T unwrap<T>(Result<T> result) => switch (result) {
Ok(:final value) => value,
Err(:final error) => throw error,
};
The Full Modifier Family
sealed arrived as part of a coherent set of class modifiers introduced in Dart 3. Each one controls a different axis of how a class can be used from other libraries:
| Modifier | Can be constructed? | Can be extended outside? | Can be implemented outside? |
|---|---|---|---|
| (none) | Yes | Yes | Yes |
abstract | No | Yes | Yes |
base | Yes | Yes | No |
interface | Yes | No | Yes |
final | Yes | No | No |
sealed | No (implicitly abstract) | No | No |
abstract — hide a partial implementation
The classic modifier. The class cannot be instantiated, but any library can extend or implement it. Use it when you want to share a partial implementation without allowing direct construction.
base — force inheritance of the implementation
base guarantees that the base constructor runs on subtype creation and that private members exist in subtypes. Other libraries can extend a base class, but they cannot implements it — which prevents third-party code from replacing your implementation with an empty shell. Any class extending or implementing a base class must itself be marked base, final, or sealed, so the guarantee propagates down the hierarchy.
base class Vehicle {
// Inherited implementation is guaranteed to exist in every subtype.
}
interface — implementation lives elsewhere
The mirror image of base: other libraries can implement the class but not extend it. Instance methods that call methods on this always dispatch to a known implementation, which sidesteps the fragile base class problem. Combining abstract interface produces a pure interface with no implementation at all.
final — close the hierarchy completely
final blocks both extension and implementation outside the library. It encompasses the effects of base, so subclasses (which must live in the same library) must again be base, final, or sealed. Reach for final when you want to evolve an API without worrying about downstream code depending on its internals.
Combining Modifiers
Modifiers stack in a fixed order: abstract (optional), then one of base / interface / final / sealed (optional), then mixin (optional), then class:
abstract base class Repository {}
abstract interface class UseCase {}
base mixin Validator {}
Invalid combinations are rejected by the analyzer:
abstract sealed— redundant, sincesealedis already abstract.interface mixin,final mixin,sealed mixin— these modifiers block mixing in, so onlybasemay modify amixindeclaration.
Modifiers do not apply to enums, typedefs, extensions, or extension types, and they require a language version of 3.0 or later.
Choosing Between Sealed and Final
Both close the hierarchy, so the choice comes down to exhaustiveness:
sealed— you want compiler-checked exhaustive switches over a fixed set of subtypes. Ideal for state classes, result types, and finite domain models. If you later add a subtype, every non-exhaustive switch breaks — which is exactly the point.final— you want a closed type but no exhaustiveness pressure. Adding a private subclass later will not break downstream switches. The Dart docs explicitly recommendfinalwhen you might extend the subtype set without breaking the public API.
A useful rule of thumb from the Dart documentation: model your states with sealed, model your value objects with final, and reserve base and interface for the boundaries where you need to protect an implementation or define a contract.
Sealed Classes and Freezed
In real projects, sealed hierarchies are often generated rather than hand-written. Freezed unions compile to exactly this pattern:
@freezed
sealed class UserDetailsState with _$UserDetailsState {
const factory UserDetailsState.loading() = Loading;
const factory UserDetailsState.loaded({required User user}) = Loaded;
const factory UserDetailsState.error({required String message}) = Error;
}
The generated _$UserDetailsState hierarchy is sealed, so pattern matching stays exhaustive, and you additionally get copyWith, ==, hashCode, and JSON serialization for free. Whether you write the hierarchy by hand or generate it, the exhaustiveness guarantee is the same.
Summary
Sealed classes trade flexibility for compiler enforcement. By constraining subtypes to a single library, they let the Dart analyzer verify that every switch over your type hierarchy is complete — turning a common class of runtime bugs into build failures.
For state management specifically, they have become the idiomatic foundation in 2026: Bloc states, Riverpod async value wrappers, and result types all lean on exhaustive pattern matching. Combined with base, interface, and final for the rest of your type boundaries, they give you precise control over how your public APIs can be extended — and by whom.