Dart Object-Oriented Programming

Sealed Classes & Pattern Matching

50 min Lesson 17 of 24

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
}
Key Rule: Sealed classes cannot be directly instantiated -- they can only be extended or implemented by classes in the same library (same file or same 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"
Tip: This is far superior to using an enum with a 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
Warning: Do not use sealed classes when you want your hierarchy to be extensible by others. If a library exposes a sealed class, consumers cannot add their own subtypes. Use 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
}
Tip: The Result pattern is extremely popular in Flutter apps for API calls, database queries, and form validation. Libraries like 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]
}
Real-World Pattern: In Flutter with BLoC or Riverpod, sealed classes are the standard way to represent states. Each state subtype carries exactly the data needed for that state -- 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.
Common Mistake: Do not try to put sealed subtypes in separate files. All direct subtypes of a sealed class must be in the same library file. You can have subtypes of subtypes in other files, but the direct children must be co-located. This is by design -- it is how Dart guarantees exhaustiveness.