Sealed Classes & Pattern Matching
What Are Sealed Classes?
Dart 3 introduced sealed classes -- a powerful way to define a closed set of subtypes. When you mark a class as sealed, the compiler knows every possible subtype at compile time. This means you can use switch expressions and the compiler will tell you if you missed a case. Sealed classes combine the safety of enums with the flexibility of class hierarchies.
Think of a sealed class like a sealed envelope: once you define what’s inside, nothing else can be added from outside the same library. Every subclass must be defined in the same file as the sealed class.
Your First Sealed Class
// All subtypes MUST be in this same file
sealed class Shape {}
class Circle extends Shape {
final double radius;
Circle(this.radius);
}
class Rectangle extends Shape {
final double width;
final double height;
Rectangle(this.width, this.height);
}
class Triangle extends Shape {
final double base;
final double height;
Triangle(this.base, this.height);
}
// This function is EXHAUSTIVE -- the compiler checks all cases
double area(Shape shape) {
return switch (shape) {
Circle(radius: var r) => 3.14159 * r * r,
Rectangle(width: var w, height: var h) => w * h,
Triangle(base: var b, height: var h) => 0.5 * b * h,
};
// No default needed! The compiler knows all subtypes.
}
void main() {
final shapes = [Circle(5), Rectangle(10, 4), Triangle(6, 3)];
for (final s in shapes) {
print('Area: ${area(s).toStringAsFixed(2)}');
}
// Area: 78.54
// Area: 40.00
// Area: 9.00
}
part). If you try to extend a sealed class from another file, the compiler will throw an error.Exhaustive Pattern Matching
The biggest benefit of sealed classes is exhaustive checking. When you switch on a sealed type, the Dart analyzer verifies you have handled every possible subtype. If you add a new subtype later, every switch expression that forgot the new case will show a compile-time error. This eliminates an entire category of bugs.
Exhaustive Switch Expressions
sealed class AuthState {}
class Authenticated extends AuthState {
final String username;
final String token;
Authenticated(this.username, this.token);
}
class Unauthenticated extends AuthState {}
class AuthLoading extends AuthState {}
class AuthError extends AuthState {
final String message;
AuthError(this.message);
}
// The compiler FORCES you to handle all 4 cases
String describeAuth(AuthState state) {
return switch (state) {
Authenticated(username: var user) => 'Welcome back, $user!',
Unauthenticated() => 'Please sign in.',
AuthLoading() => 'Loading...',
AuthError(message: var msg) => 'Error: $msg',
};
}
// If you add a new subtype later:
// class AuthExpired extends AuthState {}
// The compiler immediately warns:
// "The type 'AuthState' is not exhaustively matched -- missing AuthExpired"
default case. With an enum + default, adding a new value silently falls through to the default. With sealed classes, forgetting a case is a compile-time error, not a runtime bug.Switch Expressions with Destructuring
Dart 3’s switch expressions are more powerful than traditional switch statements. You can destructure object properties directly in the pattern, use guard clauses with when, and return values inline.
Advanced Pattern Matching
sealed class ApiResponse {}
class Success extends ApiResponse {
final Map<String, dynamic> data;
final int statusCode;
Success(this.data, this.statusCode);
}
class Failure extends ApiResponse {
final String error;
final int statusCode;
Failure(this.error, this.statusCode);
}
class Loading extends ApiResponse {}
// Destructuring + guard clauses
String handleResponse(ApiResponse response) {
return switch (response) {
Success(data: var d, statusCode: 200) => 'OK: ${d.length} fields',
Success(data: var d, statusCode: 201) => 'Created: ${d.length} fields',
Success(statusCode: var code) => 'Success with code $code',
Failure(error: var e, statusCode: 404) => 'Not found: $e',
Failure(error: var e, statusCode: 500) => 'Server error: $e',
Failure(error: var e, statusCode: var c) when c >= 400 => 'Client error $c: $e',
Failure(error: var e, statusCode: var c) => 'Error $c: $e',
Loading() => 'Please wait...',
};
}
void main() {
final responses = [
Success({'name': 'Dart'}, 200),
Failure('User not found', 404),
Loading(),
Failure('Bad request', 422),
];
for (final r in responses) {
print(handleResponse(r));
}
// OK: 1 fields
// Not found: User not found
// Please wait...
// Client error 422: Bad request
}
Sealed vs Abstract vs Enum
Choosing the right tool depends on your needs. Here is a comparison:
When to Use Each
// ENUM -- Simple, fixed set of values with no data
enum Color { red, green, blue }
// Use when: values are just labels, no extra data needed
// ABSTRACT CLASS -- Open hierarchy, anyone can extend
abstract class Animal {
String get sound;
}
// Use when: you want third-party libraries to add subtypes
// SEALED CLASS -- Closed hierarchy, exhaustive matching
sealed class Result<T> {}
class Ok<T> extends Result<T> {
final T value;
Ok(this.value);
}
class Err<T> extends Result<T> {
final String error;
Err(this.error);
}
// Use when: you know ALL possible subtypes upfront
// and want compile-time safety
abstract class or abstract interface class for open hierarchies.The Result Type Pattern
One of the most practical uses of sealed classes is the Result type, which replaces exceptions for expected errors. Instead of throwing and catching, you return either a success or a failure, and the compiler forces callers to handle both cases.
Result Type with Sealed Classes
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
final T value;
const Success(this.value);
}
class Failure<T> extends Result<T> {
final String message;
final Exception? exception;
const Failure(this.message, [this.exception]);
}
// A service that returns Result instead of throwing
class UserRepository {
final Map<int, String> _users = {1: 'Alice', 2: 'Bob'};
Result<String> findUser(int id) {
if (id <= 0) {
return Failure('Invalid ID: must be positive');
}
final name = _users[id];
if (name == null) {
return Failure('User #$id not found');
}
return Success(name);
}
}
void main() {
final repo = UserRepository();
// The compiler ensures you handle both Success and Failure
for (final id in [1, 3, -1]) {
final result = repo.findUser(id);
final message = switch (result) {
Success(value: var name) => 'Found: $name',
Failure(message: var msg) => 'Error: $msg',
};
print(message);
}
// Found: Alice
// Error: User #3 not found
// Error: Invalid ID: must be positive
}
fpdart and dartz provide ready-made Result/Either types, but understanding how to build your own with sealed classes gives you full control.State Machines with Sealed Classes
Sealed classes are perfect for modeling state machines where an object can only be in one of a known set of states. This is especially useful in Flutter for managing UI states like loading, error, and data-loaded screens.
UI State Machine
// Every possible state for a screen
sealed class PageState<T> {}
class Initial<T> extends PageState<T> {}
class Loading<T> extends PageState<T> {
final double? progress;
Loading([this.progress]);
}
class Loaded<T> extends PageState<T> {
final T data;
final DateTime loadedAt;
Loaded(this.data) : loadedAt = DateTime.now();
}
class Error<T> extends PageState<T> {
final String message;
final bool canRetry;
Error(this.message, {this.canRetry = true});
}
class Empty<T> extends PageState<T> {
final String hint;
Empty(this.hint);
}
// Simulates building a widget tree based on state
String buildUI(PageState<List<String>> state) {
return switch (state) {
Initial() => '[Welcome screen]',
Loading(progress: null) => '[Spinner]',
Loading(progress: var p) => '[Progress: ${(p! * 100).toInt()}%]',
Loaded(data: var items) when items.isEmpty
=> '[Empty: No items found]',
Loaded(data: var items) => '[List: ${items.length} items]',
Error(message: var m, canRetry: true)
=> '[Error: $m] [Retry Button]',
Error(message: var m, canRetry: false)
=> '[Error: $m] [Go Back Button]',
Empty(hint: var h) => '[Empty state: $h]',
};
}
void main() {
final states = [
Initial<List<String>>(),
Loading<List<String>>(0.65),
Loaded(['Dart', 'Flutter', 'Firebase']),
Error<List<String>>('Network timeout', canRetry: true),
Empty<List<String>>('Try a different search'),
];
for (final s in states) {
print(buildUI(s));
}
// [Welcome screen]
// [Progress: 65%]
// [List: 3 items]
// [Error: Network timeout] [Retry Button]
// [Empty state: Try a different search]
}
Loading might have a progress value, Loaded has the data, and Error has the message. The UI widget just switches on the state and renders the appropriate widget.