Sealed classes and class modifiers

Dart · Advanced · 12 min

Sealed hierarchies plus pattern matching give algebraic data types, ideal for states, results and events.

A `sealed` class is implicitly abstract and can only be extended or implemented within its own library. Because the compiler knows every direct subtype, a switch over a sealed type is checked for exhaustiveness:

sealed class Result<T> {}
class Ok<T> extends Result<T> { Ok(this.value); final T value; }
class Err<T> extends Result<T> { Err(this.error); final Object error; }

switch (r) { Ok(:final value) => ..., Err(:final error) => ... } needs no default, and adding a new subclass turns every incomplete switch into a compile error. This is the Dart 3 way to model UI states (Loading, Loaded, Failed), API results and events.

Dart 3 also adds modifiers that control how classes may be used outside their library:

  • base — can be extended but not implemented; subclasses must be base, final or sealed. Guarantees your constructor and private members run.
  • interface — can be implemented but not extended.
  • final — neither extended nor implemented outside the library.
  • abstract — can't be instantiated; combine with the others (abstract interface class is a pure interface).
  • mixin class — usable as both a class and a mixin.

Inside the declaring library these restrictions don't apply. Use them when publishing packages to keep the freedom to evolve classes without breaking users.

Example

sealed class LoadState {}
class Loading extends LoadState {
  Loading(this.progress);
  final double progress;
}
class Loaded extends LoadState {
  Loaded(this.items);
  final List<String> items;
}
class Failed extends LoadState {
  Failed(this.message);
  final String message;
}
String render(LoadState s) => switch (s) {    // exhaustive: no default
      Loading(:final progress) => 'Loading ${(progress * 100).round()}%',
      Loaded(items: []) => 'Nothing yet',
      Loaded(:final items) => '${items.length} items',
      Failed(:final message) => 'Error: $message',
    };
void main() {
  print(render(Loading(0.25)));            // Loading 25%
  print(render(Loaded([])));               // Nothing yet
  print(render(Loaded(['a', 'b'])));       // 2 items
  print(render(Failed('offline')));        // Error: offline
}

Remember

  • sealed = closed set of subtypes in one library, enabling exhaustive switches
  • Object patterns like Loaded(:final items) destructure fields
  • base, interface, final restrict extends/implements outside the library

Watch out for

  • Splitting a sealed hierarchy across files without part, which isn't allowed — subtypes must be in the same library.

Check yourself

  1. Why can a switch over a sealed class omit a default case?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: dart.dev/language/class-modifiers