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 1015 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 1520. 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 2731 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.

--

--

Anna Leushchenko 👩‍💻💙📱🇺🇦
Tide Engineering Team

Google Developer Expert in Dart and Flutter | Author, speaker at tech events, mentor, OSS contributor | Passionate mobile apps creator