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 toJson extension 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:

  1. Closed hierarchies the compiler understands.
  2. Declarative, exhaustive switch expressions.
  3. 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.