Basic and advanced networking in Dart and Flutter — the Tide way. Part 2: data models with freezed and json_serializable. Advanced.

--

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 give more information on what can be done with the help of freezed and json_serializable packages.

In this part:

1. JSON converters
2. unions in JSON
3. generics in JSON

For basic topics check out Part 1. It talks about:

1. plain data model
2. JSON serialization
3. JsonKey attribute
4. enums in JSON

0. Prerequisites

Further code examples are built on top of the code developed in Part 1, which can be found under the part-1 tag in the Flutter Advanced Networking GitHub repository.

1. JSON converters

As was shown in Part 1, section 2, 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. But what if for some reason a different DateTime format is used by backend, or if data type in JSON does not match the one in the data class? json_serializable offers a converters mechanism.

Going back to MarvelComic model from Part 1, the backend sends int id field and int? digitalId field:

Generated .g.dart contains:

Imagine now for some reason in the application it is required to use String id. The first thing to do is to create a child of a JsonConverter class:

This converter is designed to deserialize an int type field from JSON to a String type field in the Dart model. To do the opposite conversion, a base class should be JsonConverter<int, String>.

Here it is applied to id field of now String type on line 8:

As a result, IntToStringConverter automatically gets applied in the generated file on lines 7 and 15:

It might seem logical that similarly a child of JsonConverter<String, int> can be applied to convert digitalId field from int? to String?. However, nullable types are not the same as their non-nullable counterparts, and because of type mismatch, such a converter simply would not get applied in the generated file. Instead, a converter should explicitly specify that it is designed to convert nullables:

After it is applied to digitalId field of now String? type on line 9:

it also gets used in the generated .g.dart file on lines 8 and 16:

So far, converting String ids to int ids and vice-versa, both nullable and non-nullable, is the only use-case of converters for Tide projects.

2. Unions in JSON

Among other features, freezed offers unions support. Let’s take a look at the example.

Marvel Comic API exposes a StorySummary object that describes a story in a Marvel comic, and stories can be of three types: cover, interior, and promo.

A MarvelStorySummary class is a freezed model declared in the same way the MarvelComic from Part 1 is. The difference is, MarvelStorySummary has three named constructors: .cover on line 10, .interior on line 15, and .promo on line 21. And three corresponding inner classes: _CoverMarvelStorySummary on line 13, _InteriorMarvelStorySummary on line 18, and _PromoMarvelStorySummary on line 24.

This model is somewhat similar to this plain Dart classes hierarchy:

but better. The generated .freezed.dart file contains additional methods for pattern matching: map, mapOrNull, maybeMap, when, whenOrNull, maybeWhen. It is a very useful feature that we heavily use at Tide, so really, check out the docs.

Further, we’ll focus on how to keep using code generation with json_serializable to automatically parse this type of complex data structure from JSON.

The generated .g.dart file contains instructions on how to parse _CoverMarvelStorySummary model on line 5, _InteriorMarvelStorySummary model on line 21, _PromoMarvelStorySummary model on line 37.

And the generated .freezed.dart file contains a switch which decides what model type to parse based on the runtimeType value:

By default, in order to properly parse a MarvelStorySummary object to one of its union subtypes, the incoming json has to have a runtimeType key with either cover,interior, or promo value. Obviously, it’s unlikely that the backend would send such a property with exactly these values, especially the API we don’t own. However, it has to send some kind of distinction. In the case of StorySummary object from Marvel Comic API, it’s a type field that can have either “cover”, “interiorStory”, or “promo” value.

We are lucky that freezed package provides ways to customize both the union type key on line 5 and constructor names on lines 7, 12, and 17:

After these changes, the generated .freezed.dart file changes on lines 6, 7, 9, and 11:

Which means MarvelStorySummary field can now be included in MarvelComic model and be seamlessly converted to/from JSON when the MarvelComic instance is.

But what if it’s not that simple, or you need more control over what’s happening during unions parsing. Let’s take a look at another example: CreatorSummary object from Marvel Comic API. It has a role property, which can have editor, writer, inker, penciller, penciller (cover), colorist, and other values. First, it is required to parse penciller (cover) as a regular penciller model. And second, as with unknown enums from Part 1 section 4, you’d better be ready for new unknown creators types.

To start with, the new MarvelCreatorSummary class is also a union:

which has named constructors for all expected creator roles, and other for unknown ones.

Once again, the generated .freezed.dart file contains a switch on line 6 that decides into which union model type to parse:

To satisfy the requirements, the trick is to modify the incoming json map providing the required runtimeType key with an appropriate value based on the role field value of CreatorSummary object from Marvel Comic API.

Before passing the incoming json to _$MarvelCreatorSummaryFromJson method on line 10, it is modified by _appendRuntimeType method on line 13. There, a value of a new runtimeType map item is decided based on the json[‘role’] and a map of correspondence between backend and frontend types _runtimeTypesMap on line 16. Keys of this map are values of role field as it comes from Marvel Comic API, and values are names of the corresponding MarvelCreatorSummary union constructors. If _runtimeTypesMap does not contain such a key, an other constructor name is provided on line 14.

Now MarvelCreatorSummary field can be included in MarvelComic model and be seamlessly converted to/from JSON when the MarvelComic instance is.

We just looked at scenarios where a union type indicator is contained inside the union model itself, like the type field of a StorySummary object or the role field of CreatorSummary object from Marvel Comic API. But it’s not a rare case when such an indicator is a part of the outer wrapping model. Essentially, the solution is the same: somehow modify the incoming JSON to place a proper runtimeType key. We already saw an example when the json is modified inside fromJson factory constructor. But there is also another opportunity to modify the incoming json — the readValue method, which supports logic that requires accessing multiple values at once.

The next example will be artificial for Marvel Comic API, but nonetheless, let’s use our imagination. Such a case happens in Tide projects.

The theoretical MarvelSeriesSummary class has an enum format field on line 14 and a union metadata field on line 17.

The requirement is to parse the metadata field to one of MarvelSeriesSummaryMetadata union subtypes based on the value of format field.

Again, the generated union’s .freezed.dart file contains a switch on line 6 and relies on a runtimeType value to select a proper constructor:

And again, the task is to provide an appropriate value accessible by runtimeType key. To do so, the MarvelSeriesSummary.metadata field gets a new readValue parameter on line 12:

Here is what happens inside the _readFormatMetadataValue method:

First, format field value is read from the incoming json on line 8 the same way it’ll be read later in fromJson factory constructor.

Next, the corresponding runtimeType value is detected on line 13 by looking for format key from line 8 in the _runtimeTypes map on line 18.

Using readValue feature does not change any of the subsequent decoding logic for the field, so _readFormatMetadataValue method should return a map dedicated to MarvelSeriesSummaryMetadata class, which is json[key] on line 14. Just before returning, the runtimeType key is added to this map with the value of runtimeType variable from line 13.

These are the three ways of parsing union types from the incoming JSON.

3. Generics in JSON

Oftentimes API responses deliver data wrapped into a generic response object, containing request metadata. It obviously isn’t cool to create code duplication declaring the same API response classes over and over again, changing only the inner data class type, to parse JSON content. It is the right place for generics.

This time, it’s impossible to use freezed in combination with json_serializable, but plain json_serializable solves this task. We sacrifice the convenient comparison and copying methods freezed provides, but when it comes to API response objects, they are not really used anyways.

A new generic MarvelApiResponse<T> class is annotated with @JsonSerializable(genericArgumentFactories: true) on line 7 , and each field is annotated with @JsonKey on lines 21, 24, and 27. The default constructor is hidden because there is no need to create MarvelApiResponse instances directly. Thus, a constructor name is also provided on line 7 with @JsonSerializable(constructor: '_').

The generated .g.dart contains:

Unlike MarvelComic, the fromJson and toJson methods of a generic MarvelApiResponse<T> class accept two parameters, where the second parameter instructs how to convert an inner generic data field.

Now it's possible to parse a generic MarvelApiResponse carrying an instance of MarvelComic with:

And MarvelApiResponse can be used to carry and parse any other type that declares a fromJson(Map<String, dynamic> json) constructor.

Conclusion

We now have prepared a full-featured model with default values, converters, enums, unions, and a generic API response class, that are going to be used later in this series to parse and carry data obtained from Marvel Comic API.

The final version of the code developed in this part is located under the part-2 tag in the Flutter Advanced Networking GitHub repository.

Read on Part 3: HTTP client and request interceptors with dio. Basic.

--

--

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

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