Ankit Ranjan
Back to Deep Dives

Sealed Classes in Dart — Exhaustive Switching and Algebraic Data Types

When the compiler knows all possible subtypes, it can prove your switch is complete. Sealed classes bring this superpower to Dart 3.

May 24, 2026 7 topics 7 quiz questions
Share:
1

The problem — switches that lie

Consider a network request. It can succeed, fail, or still be loading. We model this with inheritance:

abstract class NetworkState {}

class Loading extends NetworkState {}
class Success extends NetworkState {
  final String data;
  Success(this.data);
}
class Error extends NetworkState {
  final String message;
  Error(this.message);
}
Now we write a switch to handle all cases:

String describe(NetworkState state) {
  switch (state) {
    case Loading():
      return 'Loading...';
    case Success(:var data):
      return 'Got: $data';
    case Error(:var message):
      return 'Failed: $message';
  }
}
This looks complete. But the compiler doesn't know that. It sees an abstract class with three known subtypes — but someone could add a fourth subtype tomorrow. A new file could declare class Timeout extends NetworkState {} and suddenly our switch is missing a case.

The compiler forces us to add a default case, just in case:

default:
    throw UnimplementedError();
That default is a lie. We don't expect it to run. But we have to write it anyway.

abstract class — the compiler can't guarantee exhaustivenessabstract class NetworkStateLoadingSuccessErrorThese are the only subtypes we know about...Timeout?Cancelled?...but anyone can add more. The switch is never provably complete.

Sealed classes fix this. They tell the compiler: "These are the only subtypes. There will never be more."

2

Sealed class syntax and rules

The fix is one word: sealed.

sealed class NetworkState {}

class Loading extends NetworkState {}
class Success extends NetworkState {
  final String data;
  Success(this.data);
}
class Error extends NetworkState {
  final String message;
  Error(this.message);
}
Now the compiler knows the complete set of subtypes. And now our switch needs no default:

String describe(NetworkState state) {
  return switch (state) {
    Loading() => 'Loading...',
    Success(:var data) => 'Got: $data',
    Error(:var message) => 'Failed: $message',
  };
}
No default. No throw UnimplementedError(). The compiler has verified that every possible case is handled.

The rules of sealed classes:

1. All subtypes must be in the same library. The sealed class and all its direct children must be in the same file (or the same library if you use part files). This is how the compiler knows it has seen everything.

2. Sealed classes are implicitly abstract. You cannot write NetworkState() — only the subtypes can be instantiated.

3. Subtypes can be classes, sealed classes, or final classes. You have full control over what each subtype allows.

sealed class Shape {}

class Circle extends Shape {
  final double radius;
  Circle(this.radius);
}

class Rectangle extends Shape {
  final double width, height;
  Rectangle(this.width, this.height);
}

// ERROR: This is in a different file
class Triangle extends Shape {}
The last line is a compile error. Triangle cannot extend Shape because Shape is sealed and Triangle is in a different library. The compiler can now guarantee that Shape is either Circle or Rectangle — nothing else.

3

Exhaustive switching — the compiler proves completeness

The real power of sealed classes shows up in switches. When the compiler knows all possible subtypes, it can check that we handle every single one.

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);
}

// This compiles — both cases handled
T unwrap<T>(Result<T> result) {
  return switch (result) {
    Ok(:var value) => value,
    Err(:var error) => throw Exception(error),
  };
}
But watch what happens if we add a new subtype:

class Pending<T> extends Result<T> {}
Now the switch in unwrap is incomplete. The compiler tells us:

Error: The type 'Result<T>' is not exhaustively matched by the switch cases since it doesn't match 'Pending<T>()'.

sealed class — the compiler guarantees exhaustivenesssealed class ResultOkErrswitch handles Ok and Err = exhaustiveNo default needed. Compiler proves the switch is complete.

This is the payoff. Every time we add a new case to the sealed hierarchy, the compiler tells us everywhere we need to update. We get compile-time errors instead of runtime crashes.

4

Sealed vs abstract vs final

Dart 3 added several class modifiers. Let's see how they compare.

abstract class: Cannot be instantiated. Can be extended by anyone, anywhere. The compiler doesn't know all subtypes.

sealed class: Cannot be instantiated. Can only be extended in the same library. The compiler knows all subtypes. Enables exhaustive switching.

final class: Can be instantiated. Cannot be extended at all. There are no subtypes.

base class: Can be instantiated and extended. Cannot be implemented (no interfaces from this class).

Class modifiers comparedModifierInstantiate?Extend?Exhaustive?Implement?abstractNoAnywhereNoYessealedNoSame libraryYesSame libraryfinalYesNoN/A (no subs)NobaseYesAnywhereNoNo(no modifier)YesAnywhereNoYes

When to use sealed: When you have a fixed set of variants and want the compiler to enforce that switches handle all of them. State machines, result types, command patterns.

When to use abstract: When you're defining a contract that many unrelated classes will implement. Database drivers, serialisation strategies, plugin interfaces.

When to use final: When the class is complete as-is and should not be extended. Value types, configuration objects, immutable data.

5

Modelling state with sealed classes

Sealed classes are perfect for modelling state machines. Let's build some common patterns.

Pattern 1: Result type

The classic Result pattern encapsulates success or failure without using exceptions for control flow.

sealed class Result<T> {
  const Result();
}

final class Ok<T> extends Result<T> {
  final T value;
  const Ok(this.value);
}

final class Err<T> extends Result<T> {
  final Object error;
  final StackTrace? stackTrace;
  const Err(this.error, [this.stackTrace]);
}

// Usage
Result<int> divide(int a, int b) {
  if (b == 0) return Err('Division by zero');
  return Ok(a ~/ b);
}

var result = divide(10, 2);
var message = switch (result) {
  Ok(:var value) => 'Result: $value',
  Err(:var error) => 'Error: $error',
};
Pattern 2: Option type

Option (also called Maybe) represents a value that might not exist, without using null.

sealed class Option<T> {
  const Option();
}

final class Some<T> extends Option<T> {
  final T value;
  const Some(this.value);
}

final class None<T> extends Option<T> {
  const None();
}

// Usage
Option<User> findUser(String id) {
  var user = database.lookup(id);
  return user != null ? Some(user) : None();
}

var greeting = switch (findUser('123')) {
  Some(:var value) => 'Hello, ${value.name}',
  None() => 'User not found',
};
Pattern 3: Async state

sealed class AsyncState<T> {}

final class Initial<T> extends AsyncState<T> {}

final class Loading<T> extends AsyncState<T> {}

final class Loaded<T> extends AsyncState<T> {
  final T data;
  Loaded(this.data);
}

final class Failed<T> extends AsyncState<T> {
  final String message;
  Failed(this.message);
}

// In a widget
Widget build(AsyncState<User> state) {
  return switch (state) {
    Initial() => Text('Press button to load'),
    Loading() => CircularProgressIndicator(),
    Loaded(:var data) => Text('Hello, ${data.name}'),
    Failed(:var message) => Text('Error: $message'),
  };
}
These patterns are called algebraic data types (ADTs). The sealed class defines a sum type — a value that is one of several variants. Each variant can carry different data. The compiler ensures we handle every variant.

6

Sealed classes with pattern matching

Dart 3 introduced both sealed classes and pattern matching. They work beautifully together.

Destructuring in switch cases:

sealed class Shape {}

final class Circle extends Shape {
  final double radius;
  Circle(this.radius);
}

final class Rectangle extends Shape {
  final double width, height;
  Rectangle(this.width, this.height);
}

double area(Shape shape) {
  return switch (shape) {
    Circle(:var radius) => 3.14159 * radius * radius,
    Rectangle(:var width, :var height) => width * height,
  };
}
The :var radius syntax extracts the field into a local variable. No casting, no temporary variables.

Guards for additional conditions:

String describe(Shape shape) {
  return switch (shape) {
    Circle(radius: var r) when r > 100 => 'Large circle',
    Circle(radius: var r) when r > 10 => 'Medium circle',
    Circle() => 'Small circle',
    Rectangle(width: var w, height: var h) when w == h => 'Square',
    Rectangle() => 'Rectangle',
  };
}
The when clause adds a guard. The case only matches if both the type matches and the guard is true.

Nested sealed hierarchies:

sealed class Event {}

sealed class UserEvent extends Event {}
final class UserLoggedIn extends UserEvent {
  final String userId;
  UserLoggedIn(this.userId);
}
final class UserLoggedOut extends UserEvent {}

sealed class SystemEvent extends Event {}
final class SystemStarted extends SystemEvent {}
final class SystemShutdown extends SystemEvent {}

void handleEvent(Event event) {
  switch (event) {
    case UserLoggedIn(:var userId):
      print('User $userId logged in');
    case UserLoggedOut():
      print('User logged out');
    case SystemStarted():
      print('System started');
    case SystemShutdown():
      print('System shutting down');
  }
}
The switch is exhaustive because it covers all leaf types in the hierarchy.

7

Real-world examples

Let's see sealed classes in action with patterns from real Flutter apps.

Example 1: Navigation events

sealed class NavigationEvent {}

final class PushRoute extends NavigationEvent {
  final String routeName;
  final Object? arguments;
  PushRoute(this.routeName, [this.arguments]);
}

final class PopRoute extends NavigationEvent {}

final class ReplaceRoute extends NavigationEvent {
  final String routeName;
  ReplaceRoute(this.routeName);
}

final class PopUntil extends NavigationEvent {
  final bool Function(Route) predicate;
  PopUntil(this.predicate);
}

void handleNavigation(NavigationEvent event, NavigatorState navigator) {
  switch (event) {
    case PushRoute(:var routeName, :var arguments):
      navigator.pushNamed(routeName, arguments: arguments);
    case PopRoute():
      navigator.pop();
    case ReplaceRoute(:var routeName):
      navigator.pushReplacementNamed(routeName);
    case PopUntil(:var predicate):
      navigator.popUntil(predicate);
  }
}
Example 2: Form validation

sealed class ValidationResult {}

final class Valid extends ValidationResult {}

final class Invalid extends ValidationResult {
  final List<String> errors;
  Invalid(this.errors);
}

ValidationResult validateEmail(String email) {
  var errors = <String>[];
  if (email.isEmpty) errors.add('Email is required');
  if (!email.contains('@')) errors.add('Invalid email format');
  return errors.isEmpty ? Valid() : Invalid(errors);
}

Widget buildEmailField(ValidationResult validation) {
  return switch (validation) {
    Valid() => TextField(decoration: InputDecoration(
        border: OutlineInputBorder(),
        suffixIcon: Icon(Icons.check, color: Colors.green),
      )),
    Invalid(:var errors) => TextField(decoration: InputDecoration(
        border: OutlineInputBorder(borderSide: BorderSide(color: Colors.red)),
        errorText: errors.first,
      )),
  };
}
Example 3: API response handling

sealed class ApiResponse<T> {}

final class ApiSuccess<T> extends ApiResponse<T> {
  final T data;
  final int statusCode;
  ApiSuccess(this.data, this.statusCode);
}

final class ApiError<T> extends ApiResponse<T> {
  final String message;
  final int? statusCode;
  ApiError(this.message, [this.statusCode]);
}

final class ApiLoading<T> extends ApiResponse<T> {}

Future<ApiResponse<User>> fetchUser(String id) async {
  try {
    final response = await http.get(Uri.parse('/users/$id'));
    if (response.statusCode == 200) {
      return ApiSuccess(User.fromJson(response.body), 200);
    }
    return ApiError('Failed to load user', response.statusCode);
  } catch (e) {
    return ApiError(e.toString());
  }
}


When to reach for sealed classesState machinesLoading | Success | ErrorUI state, async operationsResult typesOk | ErrExplicit error handlingCommands/EventsAdd | Remove | UpdateEvent-driven architecturesDomain modelsCircle | Rectangle | TriangleFixed set of variantsValidationValid | InvalidForm states, input checking

The pattern is always the same: a fixed set of possibilities, and a switch that handles each one. The compiler enforces completeness.

Test your understanding

7 questions

Seven questions covering sealed classes, exhaustive switching, and algebraic data types in Dart 3.

Search

Loading search...