DEV Community

kanta13jp1
kanta13jp1

Posted on

Dart Sealed Classes Guide — Type-Safe State Management with Pattern Matching

Dart Sealed Classes Guide — Type-Safe State Management with Pattern Matching

Dart 3's sealed classes are the evolution of enums. They restrict subtyping to a closed set and enforce exhaustive switch coverage. The result: fewer runtime errors, richer domain models, cleaner state machines.

Basic Sealed Class

sealed class AuthState {}

class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}

class AuthSuccess extends AuthState {
  final String userId;
  final String email;
  AuthSuccess({required this.userId, required this.email});
}

class AuthError extends AuthState {
  final String message;
  final String? code;
  AuthError({required this.message, this.code});
}
Enter fullscreen mode Exit fullscreen mode

Exhaustive Switch — No More Missing Cases

Widget buildFromState(AuthState state) => switch (state) {
  AuthInitial()  => const LoginForm(),
  AuthLoading()  => const CircularProgressIndicator(),
  AuthSuccess(:final email) => WelcomeScreen(email: email),
  AuthError(:final message, :final code) => ErrorView(
    message: message,
    code: code ?? 'unknown',
  ),
};
// Adding a new AuthState subclass = compile error here until handled
Enter fullscreen mode Exit fullscreen mode

Result Type

Type-safe success/failure without exceptions leaking into UI.

sealed class Result<T> {
  const Result();
  T? get valueOrNull => switch (this) {
    Success(:final value) => value,
    Failure() => null,
  };
}

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

final class Failure<T> extends Result<T> {
  final String error;
  final Object? exception;
  const Failure(this.error, {this.exception});
}

// Usage
Future<Result<UserProfile>> fetchProfile(String userId) async {
  try {
    final data = await supabase
        .from('profiles').select().eq('id', userId).single();
    return Success(UserProfile.fromJson(data));
  } catch (e) {
    return Failure('Profile fetch failed', exception: e);
  }
}

final result = await fetchProfile(userId);
switch (result) {
  case Success(:final value):
    setState(() => _profile = value);
  case Failure(:final error):
    showErrorSnackBar(error);
}
Enter fullscreen mode Exit fullscreen mode

API Response Modeling

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

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

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

final class ApiNetworkError<T> extends ApiResponse<T> {
  final String reason;
  const ApiNetworkError(this.reason);
}

// In UI
switch (response) {
  case ApiSuccess(:final data):
    setState(() => _tasks = data);
  case ApiError(:final message, :final statusCode) when statusCode == 401:
    router.go('/login');
  case ApiError(:final message):
    _showError(message);
  case ApiNetworkError(:final reason):
    _showOfflineBanner();
}
Enter fullscreen mode Exit fullscreen mode

With Riverpod

sealed class TasksState {}
class TasksLoading extends TasksState {}
class TasksData extends TasksState {
  final List<Task> tasks;
  TasksData(this.tasks);
}
class TasksError extends TasksState {
  final String message;
  TasksError(this.message);
}

// Provider
class TasksNotifier extends AsyncNotifier<TasksState> {
  @override
  Future<TasksState> build() async {
    try {
      return TasksData(await ref.read(taskRepoProvider).getAll());
    } catch (e) {
      return TasksError(e.toString());
    }
  }
}

// UI
ref.watch(tasksProvider).when(
  data: (state) => switch (state) {
    TasksLoading() => const Spinner(),
    TasksData(:final tasks) => TaskList(tasks: tasks),
    TasksError(:final message) => ErrorView(message: message),
  },
  loading: () => const Spinner(),
  error: (e, _) => ErrorView(message: e.toString()),
)
Enter fullscreen mode Exit fullscreen mode

Sealed Class vs Enum

Use case Choose
Simple flag / status enum
Each case carries data sealed class
Pattern match on variants sealed class
Immutable + copyWith sealed class + Freezed

Since switching to sealed classes for all state management, I've had ~30% fewer runtime crashes. The compiler catches every unhandled case.


Are you using sealed classes yet? What's your favorite pattern? Drop a comment below.

Top comments (0)