Tomáš Repčík - 27. 3. 2024

Stop using the Freezed map/when. Use sealed class with pattern matching

Pattern matching makes the usage of map/when generated code with Freezed redundant in Dart 3.0 and higher

Freezed library enables you to create simple data classes with immutability, cloning, shared fields and a JSON conversion properties with just a couple of lines of code. The Freezed code generator generates everything else. 

Till Dart 3.0, the Freezed library compensated for the lack of sealed classes with functions .map().when() as in the following example. 

import 'package:freezed_annotation/freezed_annotation.dart';

part 'main_screen_state.freezed.dart';

@freezed
abstract class MainScreenState with _$MainScreenState {
  const MainScreenState._();

  const factory MainScreenState.init() = Init;

  const factory MainScreenState.loading() = Loading;

  const factory MainScreenState.content(List<String> data) = Content;
}

Further usage of  .map()/.when() is then like:

void onStateChange(MainScreenState state) {
  state.when(
      init: () {},
      loading: () {},
      content: (List<String> data) {}
  ); 
  
  // OR
  
  state.map(
      init: (init) {},
      loading: (loading) {},
      content: (content) {}
  );
}

This can be completely avoided in Dart 3.0 and higher with new keywords such as sealed. You can take the following steps to first look into the pattern matching.

Sealed keyword with Freezed

To enable native sealed classes, add to your class sealed and that it is. 

import 'package:freezed_annotation/freezed_annotation.dart';

part 'main_screen_state.freezed.dart';

// sealed in front of the main screen
@freezed
sealed class MainScreenState with _$MainScreenState {
  const MainScreenState._();

  const factory MainScreenState.init() = Init;

  const factory MainScreenState.loading() = Loading;

  const factory MainScreenState.content(List<String> data) = Content;
}

Nothing more needs to be done and you are ready to go to use the pattern matching. Be aware that the created sealed classes must be public, so if you put an underscore in front of them, you cannot use them outside of the file. 

At time of writing this article, the freezed library produces sealed class logic every time, but I hope there will be a parameter for that in the close future to disable it.

Switch statement with Freezed class

The sealed classes enable you to limit the scope of operations based on the number of available classes. Dart is using for it switch exhaustive statement. All classes must be covered. 

when/map subsidies

This would be equivalent of when/map with all the classes:

switch (state) {
  case Init():
    // your logic for Init()
  case Loading():
    // your logic for Loading()
  case Content(): 
    // your logic for Content()
    state.data // accessing the internal data - autocast 
}

This would be equivalent of maybeWhen/maybeMap with all the classes:

switch (state) {
  case Content(): 
    // your logic for Content()
    state.data // accessing the internal data - autocast 
  default:
    // your logic for other cases
}

Deconstruction of class and conditional cases

Dart provides you with a way to deconstruct the class immediately in the case without implicitly accessing it. 

switch (state) {
  case Content(data: final data):
    // your logic for the data
  default:
    // default logic
}

Or you can even create additional conditions, which case needs to meet to be picked with the keyword when , for example:

switch (state) {
  case Content(data: final data) when data.isEmpty:
    // your logic for empty content
  case Content(data: final data) when data.isNotEmpty:
    // your logic for non-empty content
  default:
    // default logic
}

You can modify the conditions to your liking and needs as in usual if-else conditions.

Returning value

There is also a simplified switch statement, which can be used to directly return value or assign value to a variable. In this type of switch statement, it is not needed to use case, break or default keywords. 

final data = switch (state) {
  Content(data: final data) => data,
  MainScreenState() => null
};

// all cases
final data = switch (state) {
  Init() => null,
  Loading() => null,
  Content(data: final data) => data,
};

case and break are replaced with => and default is replaced by the parent sealed class or declaring all the sealed classes.

This is the most helpful in building your widgets in Flutter or assigning picked variables in your function. Here is an example: 

class MainScreen extends StatelessWidget {
  final MainScreenState state;

  const MainScreen({super.key, required this.state});

  @override
  Widget build(BuildContext context) => Scaffold(
        body: switch (state) {
          Content(data: final data) => ContentScreen(
              data: data,
            ),
          MainScreenState() => const LoadingScreen()
        },
      );
}

Thanks for reading and do not forget to follow for more!

Resources

Subscribe for more
LinkedIn GitHub Medium