Basic and advanced networking in Dart and Flutter — the Tide way. Part 1: data models with freezed and json_serializable. Basic.
Feeling lost? Check out the introduction into this series.
Parts 1 and 2 of this series are dedicated to creating Dart classes containing fields to carry data obtained through API requests and logic to parse this data from and to JSON.
This part aims to create a basic implementation of a Dart class with the help of freezed and json_serializable packages.
freezed is a code generating package that enriches plain Dart objects with overrides of operator ==
and hashCode
, toString
, and useful copyWith
methods. It also helps with unions and pattern-matching. json_serializable is also a code generating package that generates toJson
and fromJson
methods for objects serialization. For motivation, installation instructions, and basic implementation details refer to the docs.
Additionally, attributes from helper freezed_annotation and json_annotation packages are used.
In this part:
1. plain data model
2. JSON serialization
3. JsonKey attribute
4. enums in JSON
For advanced topics check out Part 2. It talks about:
1. JSON converters
2. unions in JSON
3. generics in JSON
0. Prerequisites
When this series is released, the latest Flutter version is 3.0.
The latest versions of required dependencies in pubspec.yaml
file are:
Further code examples are built on top of the code with an empty Flutter project, which can be found under the part-0 tag in the Flutter Advanced Networking GitHub repository.
1. Plain data model
In order to receive some data from the backend, the first task is to create some data structures to contain this data. What would a plain Dart object look like?
Looks good, but not enough. The default toString
implementation will only print Instance of ‘MarvelComic’
. Implicit operator ==
only compares objects' references and not their content. The images
list is modifiable. There is no implicit copyWith
method, and a typical manual implementation would not allow updating nullable fields with null
values:
That’s why we at Tide use freezed for every data class.
Here is the same MarvelComic
class declaration with freezed package:
The required pieces are part
file declaration on line 5
, @freezed
attribute on line 7
, class declaration on line 8
, factory constructor declaration on line 9
, and internal class declaration on line 16
. Lines 10
–15
contain a list of fields that the generated class will contain. Positional arguments are also supported, but we at Tide prefer named parameters.
Non-nullable fields should either be required
or contain the @Default
value. We typically provide default values for lists, so there is no need to check if a list is null
, when we are only interested in whether the list contains any data.
The generated file .freezed.dart
is too big to be listed here in full, but here are its key parts.
A declaration of an inheritor of MarvelComic
class on line 5
, which contains the same fields on lines 15
–20
. A constructor uses the default value for _images
field on line 12
and 13
, and images
property on line 21
wraps inner _images
list to an unmodifiable copy:
An override of the toString
method in a human-readable way on line 9
:
Overrides of operator ==
on line 9
and hashCode
on line 22
that depend on fields values, performing a deep comparison of models and lists:
Also, a copyWith
method on line 11
with the implementation on line 28
eventually allows setting null
values to nullable class fields:
Now that we have a data model, time to serialize/deserialize it to/from JSON.
2. JSON serialization
To make a data class serializable, all it takes is to add another part
file on line 6
, and a special factory constructor on line 19
:
Thanks to these changes, freezed will redirect a request to generate serialization methods to json_serializable. As a result, a new .g.dart
file contains:
Each field is serialized to and from Map<String, dynamic>
by the key, which equals the field name. Keys can be customized with @JsonKey
attribute, which we will talk about in section 3. Field types int
, double
, String
, DateTime
, List
and Map
, enums and many more are supported. DateTime
is automatically converted to and from a String
representation in Iso8601
format. The default value for a images
field is used in case JSON does not contain any value on line 12
.
Looks good, but here is a problem. If a data class contains a field of type of another data class or a list, like on lines 9
and 10
:
the generated toJson
content is somewhat unexpected:
A toJson
method was generated for a new MarvelImage
class on line 29
, but it is not called from toJson
of MarvelComic
on lines 16
and 17
.
To fix this, classes should be annotated with @JsonSerializable(explicitToJson: true)
attribute on lines 7
and 20
:
After this change, .g.dart
file content changes on lines 16
and 17
explicitly consequentially calling toJson
method on thumbnail
and images
fields:
Looks better now. We believe, explicitToJson: true
should have been the default behavior for all classes. While this is not the reality, this behavior can be configured once on a package level via build.yaml
file, which should reside next to the pubspec.yaml
file:
build.yaml
is a configuration file where all code generating packages global options can be specified.
We also use build.yaml
to provide global includeIfNull: false
configuration. So the final .g.dart
file is:
Nullable fields digitalId
, title
, modified
, thumbnail
, and format
are now written to the resulting JSON with writeNotNull
method on lines 27
–31
only if their value is not null
.
Now MarvelComic
class has functional implementations of fromJson
factory constructor and a toJson
method. Let's look into how this mechanism can be further customized.
3. JsonKey attribute
Every data class field can be annotated with @JsonKey
attribute from the json_annotation package, which is used to specify how a field is serialized. The most commonly used properties are name
, which controls field name in the resulting JSON, and ignore
, which controls whether the field is included in the resulting JSON.
For example, the thumbnail
field is given a different key name on line 13
, and theformat
field is ignored on line 12
.
The generated .g.dart
file contains:
Now it does not mention the format
field, and reads/writes the thumbnail
field from/to JSON via replaced_thumbnail_key_name
key on lines 11
and 23
. Even though most of the time, JSON keys equal fields names, we prefer explicitly specifying key names for safe refactoring.
The @JsonKey
attribute also has the defaultValue
property, but when used it only provides a fallback value for a field if its value is absent in JSON, and not when the plain constructor is used. Instead, we at Tide rely on the @Default
attribute from freezed package, because it covers both cases: when a class is created via plain MarvelComic()
constructor, and when deserialized from JSON via MarvelComic.fromJson()
constructor.
In our projects, we also use unknownEnumValue
and readValue
properties. Read on to learn more.
4. Enums in JSON
Among others, MarvelComic
class has an enum MarvelComicFormat? format
field on line 9
.
The generated to/from JSON serialization implementation in .g.dart
file uses the exact enum items names as keys in _$MarvelComicFormatEnumMap
on line 20
:
It’s worth mentioning that $enumDecodeNullable
method is declared in json_annotation package and Returns the key associated with value [source] from [enumValues], if one exists.
In reality, Marvel Comic API uses human-readable values like “Trade Paperback” or “Graphic Novel” instead of “tradePaperback” or “graphicNovel” on lines 23
and 26
. So how to keep using code generation as much as possible and adjust enum names to be deserializable? json_annotation package offers other useful attributes: @JsonEnum
and @JsonValue
.
The entire enum is annotated with @JsonEnum
on line 5
. Each enum item is annotated with @JsonValue
attribute with the value as it is going to be received from the backend. Now the generated .g.dart
file has changed to:
Now MarvelComic
is ready to properly parse MarvelComicFormat? format
field from JSON that comes from Marvel Comic API.
However, what if over time the API is extended with new comic formats? How to ensure the application remains stable without the necessity of an urgent update? The @JsonKey
attribute has another useful property — unknownEnumValue
.
First, a new unknown
item is added to MarvelComicFormat
enum on line 7
:
Next, it is provided to @JsonKey
attribute on line 9
and to the @Default
attribute on line 10
:
Now the generated .g.dart
file passes this MarvelComicFormat.unknown
to $enumDecodeNullable
method on line 9
and uses it as a fallback value on line 10
:
It means, whenever a new unknown value like “Postcard” arrives from the backend as a format
value, it will be parsed as MarvelComicFormat.unknown
. If no value for format
key is provided in the json
map, the result of $enumDecodeNullable
will be null
, but the entire expression will still fallback to MarvelComicFormat.unknown
thanks to the usage of the @Default
attribute.
Conclusion
We now have a Dart class MarvelComic
which implementation is generated with freezed package, and which can be serialized to/from JSON thanks to the implementation generated with json_serializable.
The final version of the code developed in this part is located under the part-1 tag in the Flutter Advanced Networking GitHub repository.
Read on Part 2: data models with freezed and json_serializable. Advanced.