Sealed Classes in Flutter: Unlocking Powerful Features

Ali Ammar
8 min readJun 30, 2023

Sealed classes are a powerful feature introduced to Dart in version 3, fulfilling a long-time request from developers. In the previous article, we explained how to use sealed classes and benefit from pattern matching in Dart.

In this article, we will explore how to leverage sealed classes in Flutter and how they can help us write more readable and error-free code.

Before proceeding, make sure you have a good understanding of sealed classes and pattern matching from the previous article or other sources.

State Management

State management is one of the most crucial areas where sealed classes and pattern matching shine. As the state and events can be of different types, sealed classes guarantee type safety and handling all the state for us.

Let’s see how we can combine a Cubit with sealed classes.

First, we need to define our state:

sealed class HomeState {}

class HomeStateLoading extends HomeState {}

class HomeStateLoaded extends HomeState {
final List<HomeItem> items;

HomeStateLoaded(this.items);
}

class HomeStateError extends HomeState {
final String message;

HomeStateError(this.message);
}

//our data
class HomeItem {
final String todo;
final bool isDone;

HomeItem(this.todo, this.isDone);
}

The code above should look familiar to any Cubit/BLoC user. The only difference is the addition of the sealed keyword, which allows us to combine it with pattern matching and helps us catch errors early.

Now, let’s write the Cubit:

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:union/state.dart';

class HomeCubit extends Cubit<HomeState> {
HomeCubit() : super(HomeStateIdeal()) {
loadData();
}

Future<void> loadData() async {
emit(HomeStateLoading());
// do some work to get the data here
await Future.delayed(const Duration(seconds: 1));
emit(HomeStateLoaded([
HomeItem('Buy milk', false),
HomeItem('Buy eggs', false),
HomeItem('Buy bread', false),
]));
}

void toggleItem(int index) {
final currentState = state;
if (currentState is HomeStateLoaded) {
final items = currentState.items;
final item = items[index];
items[index] = HomeItem(item.todo, !item.isDone);
emit(HomeStateLoaded(items));
}
}
}

This is a standard cubit, nothing different here. But let’s see how we can write the home page:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:union/cubit.dart';
import 'package:union/state.dart';

class HomePage extends StatelessWidget {
const HomePage({super.key});

@override
Widget build(BuildContext context) {
final cubit = context.watch<HomeCubit>();

return Scaffold(
appBar: AppBar(
title: const Text("Home Page"),
),
body: switch (cubit.state) {
HomeStateIdeal() || HomeStateLoading() => const Center(
child: CircularProgressIndicator(),
),
HomeStateError(message: var message) => Center(
child: Text(message),
),
HomeStateLoaded(items: var items) => ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
title: Text(item.todo),
trailing: Checkbox(
value: item.isDone,
onChanged: (_) => cubit.toggleItem(index),
),
);
},
),
},
);
}
}

Here comes the power of pattern matching. Notice the special features here:

  1. We use the switch as an expression and assign the value returned from it to the body (expression vs statement)
  2. We use the new syntax for matching HomeStateIdeal() and =>
  3. We use the || operator to combine both HomeStateIdeal and HomeStateLoading states in one case.
  4. We destructure the value from the error and loaded states directly inside the case and use it in our widgets.
  5. If you remove a case, you will receive an error, ensuring that all possible cases are handle

There’s more to explore. Let’s take a look at the following edits in the example:

      body: switch (cubit.state) {
HomeStateIdeal() || HomeStateLoading() => const Center(
child: CircularProgressIndicator(),
),
HomeStateError error => Center(
child: Text(error.message),
),
HomeStateLoaded(items: var items) when items.isNotEmpty =>
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
title: Text(item.todo),
trailing: Checkbox(
value: item.isDone,
onChanged: (_) => cubit.toggleItem(index),
),
);
},
),
HomeStateLoaded() => const Center(
child: Text("No items"),
),
},

6. Did you notice how we define a error variable that holds the state and casts its type to HomeStateError? Previously, we would have done something like this

if (cubit.state is HomeStateError) {
Center(
child: Text((cubit.state as HomeStateError).message),
);
}

7. Did you notice how we use the when statement (guard clause) to ensure that the items are not empty before entering the case? Previously, we would have done something like this:

if(cubit.state is HomeStateLoaded)
if((cubit.state as HomeStateLoaded).items.isNotEmpty)
Text(((cubit.state as HomeStateLoaded).items);
else Text("empty);

8. Did you see how we handle the HomeStateLoaded case? If the items are not empty, we handle them one way, and if the items are empty, we handle them differently

These changes demonstrate the power of pattern matching. By using switch expressions, we can handle different states succinctly and efficiently. The when statement allows us to handle specific cases based on their conditions, simplifying the code and making it easier to read and understand.

the use for pattern matching is unlimited, especially with the when keyword see more details about it (in the docs), we explain how to use it with cubit and the same thing is applicable with any other state management like riverpod/bloc/provider and more, you could also benefit from Freezed package to simplify the code more as explained in the previous article

Data modeling

In the above example, we defined our HomeItem as a simple class that takes a string and a boolean. But what if we want different types of items, such as images, videos, URLs, and more? Let's see how we can use sealed classes for this:


//our data
sealed class HomeItem {
final bool isDone;
HomeItem(this.isDone);
}

class StringHomeItem extends HomeItem {
final String todo;

StringHomeItem(this.todo, bool isDone) : super(isDone);
}

class ImageHomeItem extends HomeItem {
final String url;

ImageHomeItem(this.url, bool isDone) : super(isDone);
}

Our cubit will also change:

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:union/state.dart';

class HomeCubit extends Cubit<HomeState> {
HomeCubit() : super(HomeStateIdeal()) {
loadData();
}

Future<void> loadData() async {
emit(HomeStateLoading());
// do some work to get the data here
await Future.delayed(const Duration(seconds: 1));
emit(HomeStateLoaded([
StringHomeItem("Buy milk", false),
StringHomeItem("Buy eggs", false),
ImageHomeItem("https://picsum.photos/200", false),
]));
}

void toggleItem(int index) {
final currentState = state;
if (currentState is HomeStateLoaded) {
final items = currentState.items;
final item = items[index];
switch (item) {
case StringHomeItem():
items[index] = StringHomeItem(item.todo, !item.isDone);
case ImageHomeItem():
items[index] = ImageHomeItem(item.url, !item.isDone);
}
emit(HomeStateLoaded(items));
}
}
}

I know the toggleItem is messy somehow but if you use a Freezed we could benefit from the copyWith function to solve this cleaner.

Now, let’s define a separate widget to render the item:

class HomeItemWidget extends StatelessWidget {
final HomeItem item;
const HomeItemWidget({super.key, required this.item});

@override
Widget build(BuildContext context) {
return switch (item) {
StringHomeItem(todo: var todo, isDone: var isDone) => Text(todo,
style: TextStyle(
decoration:
isDone ? TextDecoration.lineThrough : TextDecoration.none,
)),
ImageHomeItem(url: var url, isDone: var isDone) => Image.network(
url,
color: isDone ? Colors.grey : null,
),
};
}
}

Next, we will use this widget inside our ListView:

  ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
title: HomeItemWidget(item: item),
trailing: Checkbox(
value: item.isDone,
onChanged: (_) => cubit.toggleItem(index),
),
);
},
),

And that’s it! By modeling our data as a sealed class, we can benefit from the switch statement when building the widget. Additionally, if we add a new type to the HomeItem model, it will notify us to update the HomeItemWidget as well.

Other Usecase

Sealed classes, along with pattern matching, can be utilized in various other use cases. Here, we’ll introduce a simple example for some practical scenarios:

Representing Errors

sealed class AppError {}

class NetworkError extends AppError {
final String message;

NetworkError(this.message);
}

class ServerError extends AppError {
final int statusCode;

ServerError(this.statusCode);
}

class ClientError extends AppError {
final int statusCode;

ClientError(this.statusCode);
}

class GenericError extends AppError {
final String message;

GenericError(this.message);
}

class ApiService {
Future<String> fetchData() async {
// Simulating an API request that may result in an error
await Future.delayed(const Duration(seconds: 2));

// Uncomment the lines below to simulate different error scenarios

// throw NetworkError('No internet connection');
// throw ServerError(500);
// throw ClientError(404);
// throw GenericError('Something went wrong');

return 'Data successfully fetched';
}
}

class MyApp extends StatelessWidget {
final ApiService apiService = ApiService();

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('Error Handling'),
),
body: Center(
child: FutureBuilder<String>(
future: apiService.fetchData(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
} else if (snapshot.hasError) {
final error = snapshot.error;
if (error is AppError) {
return switch (error) {
NetworkError(message: var message) =>
Text('Network Error: $message'),
ServerError(statusCode: var statusCode) =>
Text('Server Error: $statusCode'),
ClientError(statusCode: var statusCode) =>
Text('Client Error: $statusCode'),
GenericError(message: var message) =>
Text('Generic Error: $message'),
};
} else {
return Text('Unknown error');
}
} else {
return Text(snapshot.data ?? 'No data available');
}
},
),
),
),
);
}
}

For a more functional approach, we can use record too:

class ApiService {
Future<(String?, AppError?)> fetchData() async {
// Simulating an API request that may result in an error
await Future.delayed(const Duration(seconds: 2));

return (null, NetworkError('No internet connection'));

return ('Data successfully fetched', null);
}
}

Handling Events with BLoC

import 'package:flutter_bloc/flutter_bloc.dart';

// Sealed class representing different types of events
sealed class Event {}

class ButtonPressEvent extends Event {
final String buttonId;

ButtonPressEvent(this.buttonId);
}

class InputChangeEvent extends Event {
final String inputText;

InputChangeEvent(this.inputText);
}

class TimerTickEvent extends Event {
final int tick;

TimerTickEvent(this.tick);
}

class EventBloc extends Bloc<Event, String> {
EventBloc() : super('') {
on<Event>((event, emit) {
switch (event) {
case ButtonPressEvent():
emit('Button pressed: ${event.buttonId}');
// Handle button press event
case InputChangeEvent():
emit('Input changed: ${event.inputText}');
// Handle input change event
case TimerTickEvent():
emit('Timer tick: ${event.tick}');
// Handle timer tick event
}
});
}
}

Using sealed classes and pattern matching can enhance your code readability and make it less prone to errors. Whether it’s state management, representing data types, handling API responses, data modeling, or error handling, sealed classes provide powerful features that can greatly improve your Flutter development experience.

Conclusion

In conclusion, sealed classes in Flutter offer powerful features that unlock various benefits for developers. By combining sealed classes with pattern matching, we can write more readable and error-free code. Sealed classes provide type safety and guarantee that all possible cases are handled, ensuring robust state management.

One of the key areas where sealed classes shine is state management. Flutter developers often deal with different types of states and events, and sealed classes provide a structured approach to handling them. The code examples demonstrated how sealed classes can be used with Cubit, a popular state management solution. The use of pattern matching with switch expressions simplifies the code and makes it more concise.

Furthermore, sealed classes can be applied beyond state management. They can be utilized for data modeling, where different types of data objects need to be represented. By defining sealed classes for data objects, such as the HomeItem example, we can benefit from pattern matching to handle different types of items concisely and efficiently.

The article also highlighted other use cases for sealed classes, including error handling and event handling with BLoC. Sealed classes provide a structured approach to represent different types of errors or events, allowing developers to handle them in a clear and organized way.

To further simplify code and enhance productivity, the article mentioned the Freezed package, which complements sealed classes and offers additional features like the copyWith function for more streamlined code.

Overall, sealed classes in Flutter, combined with pattern matching, offer powerful features that improve code readability, maintainability, and type safety. By leveraging sealed classes, developers can unlock a range of benefits across different aspects of Flutter development, making the development experience more efficient and enjoyable.

you could found the above code in the following repo

--

--

Ali Ammar

Flutter and Nodejs Developer from iraq, working at Creative Advanced Technologies