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;
}
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);
}
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,
);
}
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 Widget
s.
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.
Great content! Super high-quality! Keep it up! 🙂
/// 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”
},
Would Freezed be capable of replacing Equatable (i.e. in bloc)?
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?
Hey Gilles! This tutorial is for an older version of the package, it does now indeed use @freezed.
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!
I’m glad you find the DDD series helpful! A new part has just come out.
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.
Matt, I am using Clean architecture, how can I manage models and entities with freezed?
Do you update your paid boot camp? We are in 2022 now! I mean, this seems to be from 2020…!