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.