Sealed Classes in Dart: Unlocking Powerful Features

Ali Ammar
8 min readJun 28, 2023

Dart’s sealed classes provide a powerful way to define union class and leverage pattern matching. The Freezed package, known for its extensive feature set, introduced union class and pattern-matching support. However, with the native support of sealed classes in Dart starting from version 3, we can now rely on Dart’s built-in pattern-matching syntax instead of using Freezed’s generated methods.

Let’s explore the terms involved, such as union types, sealed classes, and pattern-matching, and how Dart now supports them natively.

Union Type

In TypeScript, union types are defined as

A union type is a type formed from two or more other types, representing values that may be any one of those types. We refer to each of these types as the union’s members.

so it is the ability to define a variable that can hold more than one type (string or int or other), a fake example in dart

// this is fake for explaning only
String | int x;
x=5; //ok
x="Hi"; // ok
x=true; //error

Although Dart does not support union types, understanding them helps us appreciate the need for sealed classes and how they can achieve similar functionality.

Sealed Class

Sealed classes are supported in various programming languages like C#, Kotlin, and now Dart since version 3. A sealed class cannot be extended outside of its library (file). This restriction allows the compiler to benefit from it, as we’ll see shortly.

Let’s take an example:

//home_state.dart
sealed class HomeState {}

Here, we’ve defined a sealed class called HomeState. We can proceed to extend it as follows:

//home_state.dart
sealed class HomeState {}

class LoadingState extends HomeState {}

class LoadedState extends HomeState {
final String data;

LoadedState(this.data);
}

Everything appears normal except for the sealed keyword. What does it mean? Let's try extending HomeState in a different Dart file:

//error.dart

import 'home_state.dart';
class ErrorState extends HomeState {
final String error;

ErrorState(this.error);
}

Now, you’ll notice the difference. You’ll get an error message stating:

The class ‘HomeState’ can’t be extended, implemented, or mixed in outside of its library because it’s a sealed class.

This restriction brings along an amazing feature called pattern-matching, allowing us to create a variable that acts like a union type, with each type represented as a class extending the main sealed class.

Pattern matching

Pattern-matching is a significant feature in Dart that can be used in various scenarios (for complete details, refer to the Dart documentation). We’ll focus on how it combines with sealed classes.

The Dart documentation states about sealed classes:

The compiler is aware of any possible direct subtypes because they can only exist in the same library. This allows the compiler to alert you when a switch does not exhaustively handle all possible subtypes in its cases

In other words, since the compiler now knows all the subclasses of the sealed class, it can generate an error if we miss handling any cases.

Let’s consider an example:

sealed class HomeState {}

class LoadingState extends HomeState {}

class LoadedState extends HomeState {
final String data;

LoadedState(this.data);
}

class ErrorState extends HomeState {
final String message;

ErrorState(this.message);
}

void main(List<String> args) {
final HomeState state = LoadedState('data');
switch (state) {
case LoadingState():
print('LoadedState');
break;
case LoadedState():
print('LoadedState');
break;
case ErrorState():
print('ErrorState');
break;
}
}

//Tips: in dart 3 we dont need the break anymore

Notice the switch case statement and how we handle the different cases. The new syntax case LoadingState(): represents matching in Dart(this is not initialized of an object).

If we remove one case from the code snippet:

void main(List<String> args) {
final HomeState state = LoadedState('data');
switch (state) {
case LoadingState():
print('LoadedState');
break;
case LoadedState():
print('LoadedState');
break;
}
}

You’ll receive an error indicating a missing case and suggesting adding it or incorporating a default case:

The type ‘HomeState’ is not exhaustively matched by the switch cases since it doesn’t match ‘ErrorState()’.
Try adding a default case or cases that match ‘ErrorState()’

Tips from me: avoid adding a default case unless necessary.

Before sealed classes, the compiler couldn’t warn us about such errors since it didn’t know all the cases for a class. This is precisely what Freezed provided but with a different approach using the when and map functions and code generation, which are no longer required.

But that's not all. As you can see from the above switch statement, it is a statement and cannot be used to return a value or save it inside a variable. To address this, Dart 3 introduced the switch expression (statement vs expression).

Consider the following code snippet:

void main(List<String> args) {
final HomeState state = LoadedState('data');
final msg = switch (state) {
LoadingState() => "we are loading",
LoadedState() => "we are loaded",
ErrorState() => "we are error"
};
print(msg);
}

In this example, we utilize the new switch expression to assign the corresponding message based on the state value. You can also use logical operators in combination with switch expressions, like case ErrorState() || LoadedState():.

  switch (state) {
case LoadingState():
print('LoadedState');
break;
case ErrorState()|| LoadedState():
print('ErrorState');
break;
}

final msg = switch (state) {
LoadingState() => "we are loading",
ErrorState() || LoadedState() => "we are done"
};

Furthermore, we can access the data inside each state, such as data in LoadedState or message in ErrorState:

final msg = switch (state) {
LoadingState() => "we are loading",
LoadedState(data: var data) => "we are loaded $data",
ErrorState(message: var message) => "error is $message"
};

switch (state) {
case LoadingState():
print('LoadedState');
break;
case LoadedState(data: var data):
print('LoadedState $data');
break;
case ErrorState(message:var message):
print('ErrorState $message');
break;
}

As shown above, we can define a variable representing a field in the state class inside the switch case directly and utilize it accordingly.

Another useful technique is the ability to define a variable for the value matched in a switch statement.

switch (state) {  
// see how we match the LoadingState case and define a obj variable here
case LoadingState obj:
print('LoadingState $obj');
break;
// same thing but final
case final LoadedState obj:
// see how we could access Loaded data
print('LoadedState ${obj.data}');
break;
// well this is also good
case ErrorState() && var obj:
print('ErrorState ${obj}');
break;
}

This allows us to access the value with its new type directly within the case block, eliminating the need to define a separate variable or cast it before use. However, it’s needed only within a class, as the compiler cannot guarantee that the value’s type remains the same in the next line (the same issue arises with nullable values).

Another useful feature is the Guard clause, which allows us to use this variable in a condition before entering the case using the when keyword:

  final msg = switch (state) {
LoadingState() => "we are loading",
ErrorState(message: var data) => "error is $data",
LoadedState(data: var data) when data.length > 10 => "length more than 10",
LoadedState() => "length less than 10"
};

Use Case With Flutter

Sealed classes are a powerful tool in Flutter that can be used to model the state of your app in a way that is both flexible and safe. They are similar to enums, but they allow you to create more complex state machines.

One common use case for sealed classes in Flutter is to represent the different states of a user interface. For example, you could have a sealed class called UserInterfaceState with the following subclasses:

  • LoadingState
  • SuccessState
  • ErrorState

Each of these subclasses would represent a different state of the user interface, such as when the app is loading data, when the data has been loaded successfully, or when there was an error loading the data.

Sealed classes can also be used to represent the different types of data that your app can handle. For example, you could have a sealed class called Data with the following subclasses:

  • TextData
  • ImageData
  • VideoData

Each of these subclasses would represent a different type of data that your app can handle, such as text, images, or videos.

I will create another article soon on how to use a sealed class with Flutter(here it is).

What about Freezed

While Freezed offers many other features like data classes, cloning, immutability, and JSON serialization to the Dart model, these aspects are still not natively supported in Dart, and it still helps us write the sealed class with less code and without defining a lot of Class, the above example will be converted into Freezed as

import 'package:freezed_annotation/freezed_annotation.dart';

part 'home_state.freezed.dart';

@freezed
abstract class HomeState with _$HomeState {
const factory HomeState.loading() = LoadingState;
const factory HomeState.loaded(String data) = LoadedState;
const factory HomeState.error(String message) = ErrorState;
}

this is a more minor code with more power as it provides us with an immutable class,copyWith function with deep support, and toJson/fromJson if needed too.

Freezed is already supporting using sealed class just change the abstract keyword above into sealed and you could start using the pattern matching explained in this article

import 'package:freezed_annotation/freezed_annotation.dart';

part 'home_state.freezed.dart';

@freezed
sealed class HomeState with _$HomeState {
const factory HomeState.loading() = LoadingState;

const factory HomeState.loaded(String data) = LoadedState;

const factory HomeState.error(String message) = ErrorState;
}

void main(List<String> args) {
final HomeState state = const LoadedState('data');
final msg = switch (state) {
LoadingState() => "we are loading",
LoadedState() => "we are loaded",
ErrorState() => "we are error"
};
print(msg);
}

Tip freezed user: stop using the when/map function in freezed as it will be removed in the future version of freezed, if you are already used it there may be a tools for migration in the future (see)

Conclusion

In conclusion, the addition of sealed classes in Dart 3 has unlocked powerful features in the language. Sealed classes provide a way to define union Class and leverage pattern-matching, which improves code correctness and expressiveness.

With sealed classes, we can create a hierarchy of classes where the subclasses represent different states or types. The sealed keyword ensures that the classes can only be extended within the same library, allowing the compiler to detect missing cases in pattern-matching.

Pattern-matching, combined with sealed classes, enables us to handle different cases exhaustively and detect any omissions or mistakes at compile-time. This feature enhances code safety and reduces the likelihood of runtime errors.

Sealed classes have proven to be particularly useful in Flutter development, where they can represent different states of a user interface or different types of data. They provide a flexible and safe way to model complex state machines and handle various data types.

While Freezed still offers additional features like immutability, data classes, and JSON serialization, sealed classes in Dart provide a more concise and powerful solution. Freezed remains a valuable option for developers who require advanced features not natively supported in Dart.

To leverage the benefits of sealed classes and pattern-matching, it is important to stay updated with the latest Dart documentation and language releases. By adopting these features, developers can write more expressive, type-safe, and efficient code in Dart projects.

See part two for this article that talks about sealed classes with flutter

--

--

Ali Ammar

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