Freezed ❄ – Data Class & Union in One Dart Package

11  comments

How would you like it if Dart had data classes and sealed classes as we know them from Kotlin? It would be perfect but while these features are being worked on, they're still in the distant future. Now we have the next best thing though - we're all about to get freezed with the new package by Rémi Rousselet, the creator of provider.

But there's a package for that...

If you've been following Dart's algebraic data type scene for a while you might be thinking "We have sealed_unions, super_enum, sum_types... Why do we need yet another package?". (Yes, I did cover all of those packages with tutorials ?) Similarly, if you are an avid follower of the data class scene, built_value, equatable and even certain VS Code extensions may come to mind. So, why again another package?

I could talk for quite some time about the insufficiencies of the aforementioned packages so I'll keep it short. They are either too verbose, too restrictive or too ugly to use on a daily basis. That is a reason in itself to switch to freezed which provides a succint and elegant syntax.

Still not convinced? Well, freezed can be used for both data classes and unions! This means you'll get automatically generated value equality, copyWith, exhaustive switch, and even JSON serialization support from one place! Basically you get built_value and sum_types without all the weirdness and boilerplate.

Setting up the project

We will use a console application in this tutorial but everything applies to Flutter as well. First, let's add the needed packages to pubspec.yaml. Apart from freezed, we're also going to add json_serializable as it's nicely integrated.

pubspec.yaml

dependencies:
  json_annotation: ^3.0.1
  meta: ^1.1.8

dev_dependencies:
  build_runner:
  freezed: ^0.1.3+1
  json_serializable: ^3.2.5

Creating a data class

In case you're not familiar with the term data class, it's simply a class with value equality, copyWith method, its fields are immutable and it usually easily supports serialization. Also, if you're familiar with Kotlin, you know that you can heavily cut down on the boilerplate by defining fields directly in the constructor like this:

kotlin_data_class.kt

data class User(val name: String, val age: Int)

Compare that to the amount of code needed with pure Dart:

dart_regular_data_class.dart

@immutable
class User {
  final String name;
  final int age;

  User(this.name, this.age);

  User copyWith({
    String name,
    int age,
  }) {
    return User(
      name ?? this.name,
      age ?? this.age,
    );
  }

  @override
  bool operator ==(Object o) {
    if (identical(this, o)) return true;

    return o is User && o.name == name && o.age == age;
  }

  @override
  int get hashCode => name.hashCode ^ age.hashCode;
}

Things aren't going that great for Dart so far. Of course, you can go with built_value but then you'll have to deal with its weird syntax. But fear not because the simplicity of freezed is just a few keystrokes away.  Let's create a new file freezed_classes.dart.

freezed_classes.dart

import 'package:meta/meta.dart';

part 'freezed_classes.freezed.dart';

@immutable
abstract class User with _$User {
  const factory User(String name, int age) = _User;
}
We're going to create all the freezed classes inside this file so that we don't have to specify the import and part statements over and over again.

Not bad, is it? Sure, there's still some amount of boilerplate but it's pretty standard and minimal. After running every Flutter developer's favorite command...

?‍? terminal

flutter pub run build_runner watch --delete-conflicting-outputs

We can now use our User data class with all it's glory and utilize immutability, value equality and copyWith.

main.dart

void main() {
  final user = User('Matt', 20);
  // user.age = 5; // error, User is immutable
  final user2 = user.copyWith(name: 'John');

  final sameValueUser1 = User('abc', 123);
  final sameValueUser2 = User('abc', 123);
  print(sameValueUser1 == sameValueUser2); // true

  print(user); // User(name: Matt, age: 20)
}

The constructor parameters of our data class User are currently positional. That doesn't need to be the case though as you can make them optional or named.

freezed_classes.dart

@immutable
abstract class User with _$User {
  const factory User(String name, {int age}) = _User;
}

Adding JSON serialization

Serializing data to and from JSON is dead simple with json_serializable and, thankfully, freezed was built to work well with it! No more custom and weird serialization as with built_value! Since we've already added it as a dependency, we just need to add the little boilerplate needed for json_serializable's generator to kick in.

Make sure to import json_annotation.dart and provide a regular part '*.g.dart' statement. Notice that we define only the fromJson method and that we don't annotate the class with @JsonSerializable.

freezed_classes.dart

import 'package:json_annotation/json_annotation.dart';
import 'package:meta/meta.dart';

part 'freezed_classes.freezed.dart';
part 'freezed_classes.g.dart';

@immutable
abstract class User with _$User {
  const factory User(String name, int age) = _User;
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

This will generate all the necessary code to call toJson as an instance method and fromJson as a factory.

main.dart

void main() {
  final user = User('Matt', 20);
  final Map<String, dynamic> serialized = user.toJson();
  final User deserialized = User.fromJson(serialized);
}
Serialization is also fully supported with unions which is very cool ? More about them below.

Creating a union/sealed class

These kind of simple data classes are enough in themselves to use freezed but the elegance with which you can create unions is just ? magnificent. If you used any other sealed class wannabe packages, you know about their weird syntax or limitations. Let's again take a look at the grace of Kotlin's sealed classes:

kotlin_sealed_classes.kt

// Nest classes to access them only as Operation.Add or Operation.Subtract
sealed class Operation {
  class Add(val value: Int) : Operation()
  class Substract(val value: Int) : Operation()
}

// Don't nest classes if you want to instantiate Add or Subtract directly
sealed class Operation
class Add(val value: Int) : Operation()
class Substract(val value: Int) : Operation()

fun main() {
  ...
  // exhaustive "switch", notifies you if you forget to handle a case
  when(operation) {
    is Add -> //do something
    is Subtract -> // do something
  }
}

Even if you know nothing about Kotlin, you should understand what's going on. Can we replicate this to a tee with freezed? You bet we can! Unlike with the other other wannabe sealed class packages, freezed allows even for the nested/non-nested paradigm. Let's see what's possible.

"Nested" sealed classes

The beauty of nested sealed classes in Kotlin is that you cannot instantiate the individual subclasses individually. With freezed, this means that writing final operation = Add(); is invalid while writing final operation = Operation.add() is valid. How can we accomplish such a thing? It's easy! Just make the generated class for the union case private.

freezed_classes.dart

@immutable
abstract class OperationNested with _$OperationNested {
  // "Nested" unions have private generated classes (underscore)
  const factory OperationNested.add(int value) = _Add;
  const factory OperationNested.subtract(int value) = _Subtract;
}

Then we can instantiate a union case by calling the factory method and use the when method to exhaustively switch over all of the possible cases.

main.dart

void main() {
  final result = performOperation(2, OperationNested.add(2));
  print(result); // 4
}

// Function pretending to do something useful
int performOperation(int operand, OperationNested operation) {
  // Like switch statement but forgetting about a case will result in info/error
  return operation.when(
    add: (value) => operand + value,
    subtract: (value) => operand - value,
  );
}
Parameters of the when method are @required. However, Dart only gives you a non-fatal "info" message by default. To promote this friendly message to be a full-blown error, enable custom lint rules.

"Non-nested" sealed classes

It's sometimes nice to not have to call factory constructors but instead instantiate individual case classes directly. This can be useful with BLoC events and states, for example. Achieving this is as simple as making the generated union case classes public.

freezed_classes.dart

@immutable
abstract class OperationNonNested with _$OperationNonNested {
  // "Non-nested" unions have public generated classes (no underscore)
  const factory OperationNonNested.add(int value) = Add;
  const factory OperationNonNested.subtract(int value) = Subtract;
}

This allows for two ways of instantiation - either use the factory or instantiate the case class directly.

main.dart

void main() {
  // Still possible to use the factory
  final result1 = performOperation(2, OperationNonNested.add(2));
  // But non-nested union cases can also be instantiated directly
  final result2 = performOperation(2, Add(2));

  print(result1); // 4
  print(result2); // 4
}

int performOperation(int operand, OperationNonNested operation) {
  // When method still works even with cases instantiated directly
  return operation.when(
    add: (value) => operand + value,
    subtract: (value) => operand - value,
  );
}

Other switch-like methods

We've seen when so far but there are 3 more methods. Of course, there's the non-exhaustive maybeWhen which allows you to ignore certain union cases and provide a fallback orElse function to run instead.

main.dart

return operation.maybeWhen(
  add: (value) => operand + value,
  // ignoring subtract
  orElse: () => -1,
);

Then there is map and its companion maybeMap. They're very similar to when and maybeWhen but instead of passing you the destructured value (int in the case of our example union), they pass you the actual case class holding the data (Add or Subtract in our example).

main.dart

int performOperation(int operand, OperationNonNested operation) {
  return operation.map(
    add: (Add caseClass) => operand + caseClass.value,
    subtract: (Subtract caseClass) => operand - caseClass.value,
  );
}

Having access to the actual case class can be useful if you want to, for example, call copyWith on it or if you're implementing a BlocBuilder widget where you want to map incoming states to Widgets.

BONUS: VS Code snippets

The boilerplate you need to write with freezed is minimal but it's still there. I'm sure you'll find these snippets useful.  Just hit F1 or Ctrl/Cmd + Shift + P, input "Configure User Snippets" and edit the dart.json file.

dart.json

{
  ...
  "Part statement": {
    "prefix": "pts",
    "body": [
      "part '${TM_FILENAME_BASE}.g.dart';",
    ],
    "description": "Creates a filled-in part statement"
  },
  "Part 'Freezed' statement": {
    "prefix": "ptf",
    "body": [
      "part '${TM_FILENAME_BASE}.freezed.dart';",
    ],
    "description": "Creates a filled-in freezed part statement"
  },
  "Freezed Data Class": {
    "prefix": "fdataclass",
    "body": [
      "@immutable",
      "abstract class ${1:DataClass} with _$${1:DataClass}{",
      "  const factory ${1:DataClass}(${2}) = _${1:DataClass};",
      "}"
    ],
    "description": "Freezed Data Class"
  },
  "Freezed Union": {
    "prefix": "funion",
    "body": [
      "@immutable",
      "abstract class ${1:Union} with _$${1:Union}{",
      "  const factory ${1:Union}.${2}(${4}) = ${3};",
      "}"
    ],
    "description": "Freezed Union"
  },
  "Freezed Union Case": {
    "prefix": "funioncase",
    "body": [
      "const factory ${1:Union}.${2}(${4}) = ${3};"
    ],
    "description": "Freezed Union Case"
  },
  ...
}

Show some support to Rémi by giving this package a like on pub and a star on GitHub! We finally have a a data/sealed class solution which doesn't compromise on anything.

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a freelancer and most importantly developer educator, he is set on helping other people succeed in their Flutter app development career.

You may also like

  • /// dart.json for input classname auto

    “Freezed Data Class”: {
    “prefix”: “fdataclass”,
    “body”: [
    “@freezed”,
    “abstract class ${1:DataClass} with _$${1:DataClass}{“,
    ” const factory ${1:DataClass}(${2}) = _${1:DataClass};”,
    “}”
    ],
    “description”: “Freezed Data Class”
    },
    “Freezed Union”: {
    “prefix”: “funion”,
    “body”: [
    “@freezed”,
    “abstract class ${TM_FILENAME_BASE/^([a-zA-Z])|_([a-zA-Z])/${1:/capitalize}${2:/capitalize}/g} with _$${TM_FILENAME_BASE/^([a-zA-Z])|_([a-zA-Z])/${1:/capitalize}${2:/capitalize}/g}{“,
    ” const factory ${TM_FILENAME_BASE/^([a-zA-Z])|_([a-zA-Z])/${1:/capitalize}${2:/capitalize}/g}.${3}(${4}) = ${2}${3/(.*)/${1:/capitalize}/};”,
    “}”
    ],
    “description”: “Freezed Union”
    },
    “Freezed Union Case”: {
    “prefix”: “funioncase”,
    “body”: [
    “const factory ${TM_FILENAME_BASE/^([a-zA-Z])|_([a-zA-Z])/${1:/capitalize}${2:/capitalize}/g}.${3}(${4}) = ${2}${3/(.*)/${1:/capitalize}/};”
    ],
    “description”: “Freezed Union Case”
    },
    “From Json”: {
    “prefix”: “fromJson”,
    “body”: [
    “factory ${TM_FILENAME_BASE/([a-zA-Z]+)/${1:/capitalize}/i}.fromJson(Map json) => “,
    “_$${TM_FILENAME_BASE:/capitalize}FromJson(json);”
    ],
    “description”: “From Json”
    },

  • Thanks Matt for this great tutorial!

    One question: shouldn’t the annotation be `@freezed` instead of `@immutable`? How will the package know the file has to be processed otherwise?

  • Great job, Matt!

    I watched on YouTube a very good talk about DDD in Flutter Europe event (https://www.youtube.com/watch?v=lGv6KV5u75k) but it seemed too theoretical for me,
    brand new to this archtecture. Your tutorial series made it so concrete that I overhauled the project I’m working on. I was feeling a little lost in my own code and I was having a hard time to position a Bloc class and you clarified me it!

    Thanks very much! I’m eager to see the next chapter of this adventure to compare my own new AuthFacade implementation after getting to know DDD to what you’re going to present.

    Regards from Brazil!

  • Update the tutorial to replace ‘@immuatble’ with ‘@freezed’. otherwise the code will not generate.

  • There are some changes in the Freezed package. One of them is using @freezed instead of @immutable. Can you update the written tutorial? After I watch the video, I tried the Freezed package and stucked on this problem until I checked the Freezed GitHub page.

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >