Dart Abstract JSON De-/Serialisation
Imagine having a food store API working with different kinds of foods like fruit and meat. They have different properties, but you want to keep your response data model simple, so you just send and accept a basket containing a list of foods and a total price for the basket.
All food objects have some properties in common, these are put into the definition of Food
. For the concrete classes Fruit
and Meat
we add more fields as needed.
The code below should just give you a rough idea of how the data is structured. To see it working, you can have a look into my example project on GitHub.
Annotations
To generate the basic JSON serialisation and deserialisation code, we use json_annotation
and json_serializable
.
By using json_annotation
we can add a few annotations and have most of the conversion boiler plate generated for us by running flutter pub run build_runner build --delete-conflicting-outputs
.
// Tells the code generator to create the toJson
// and fromJson code for this class
// with the fields used in the constructor.
@JsonSerializable()
class Basket {
// Our converter which we will take a look at in more detail down below.
@FoodConverter()
final List<Food> foods;
// Ignore "total" field, since it's computed by the constructor.
@JsonKey(ignore: true)
final double total;
Basket(this.foods) : total = foods.fold(0, (previousValue, element) =>
previousValue + element.price);
// Here we call the generated fromJson function accepting
// a Map<String, dynamic> to create an Basket.
factory Basket.fromJson(Map<String, dynamic> json) => _$BasketFromJson(json);
// Here we call the generated toJson function.
// It's like fromJson just the other way around.
Map<String, dynamic> toJson() => _$BasketToJson(this);
}
enum FoodType {
unknown,
fruit,
meat;
// Parsing for the type from JSON.
static FoodType fromString(String typeName) =>
FoodType.values.firstWhere(
(foodType) => foodType.name == typeName,
orElse: () => FoodType.unknown,
);
}
// Our abstract food class used in the basket.
// We want to convert the classes extending Food to and from JSON,
// but still use the class Food in the basket to keep it general.
abstract class Food {
final FoodType type;
final String name;
final String barcode;
final double price;
Food(this.type, this.name, this.barcode, this.price);
// We demand our extending classes to implement toJson
// which makes the conversion easier later on.
Map<String, dynamic> toJson();
}
// Here we tell the code generator to use the named constructor "_withType".
// With this we keep the default constructor clean for use in our own code.
@JsonSerializable(constructor: "_withType")
class Fruit extends Food {
final double sweetness;
Fruit._withType(
super.type,
super.name,
super.barcode,
super.price,
this.sweetness,
);
Fruit(String name, String barcode, double price, this.sweetness) :
super(FoodType.fruit, name, barcode, price);
factory Fruit.fromJson(Map<String, dynamic> json) => _$FruitFromJson(json);
@override
Map<String, dynamic> toJson() => _$FruitToJson(this);
}
@JsonSerializable(constructor: "_withType")
class Meat extends Food {
final String animal;
final bool isRedMeat;
Meat._withType(
super.type,
super.name,
super.barcode,
super.price,
this.animal,
this.isRedMeat,
);
Meat(String name, String barcode, double price, this.animal, this.isRedMeat) :
super(FoodType.meat, name, barcode, price);
factory Meat.fromJson(Map<String, dynamic> json) => _$MeatFromJson(json);
@override
Map<String, dynamic> toJson() => _$MeatToJson(this);
}
Concept
What enables the conversion of abstract types is the field type
in the above example. It allows us to determine the actual type of the data written in the JSON object.
For convenience, we use an enum for the type and in case there is something written in JSON which we cannot decode we have the unknown
value. This allows us later during the parsing to handle types we might not have implemented yet by e.g. throwing an exception or returning a default value if appropriate.
FoodConverter
Now let’s have a look at the FoodConverter
to create the List<Food>
object from JSON and generate JSON from the List.
For that, we create a class with a constant constructor and implement the two methods toJson
and fromJson
.
class FoodConverter extends JsonConverter<List<Food>, List<Map<String, dynamic>>> {
static const String _typeKey = "type";
// const constructor is important to be able to use it as an annotation.
const FoodConverter();
@override
List<Food> fromJson(List<Map<String, dynamic>> json) => json
.map((foodJson) {
if (foodJson.containsKey(_typeKey)) {
final type = FoodType.fromString(foodJson[_typeKey]);
switch (type) {
case FoodType.unknown:
// In this case we could also throw an error
// or do some other fault handling.
break;
case FoodType.fruit:
return Fruit.fromJson(foodJson);
case FoodType.meat:
return Meat.fromJson(foodJson);
}
}
})
.whereType<Food>()
.toList();
@override
List<Map<String, dynamic>> toJson(List<Food> object) =>
// Here we can see, why we defined toJson directly on the Food class.
// It makes calling the conversion very simple.
object.map((food) => food.toJson()).toList();
}
Converting the full list of foods directly allows us to handle incorrect data by mapping these values to null
(implicitly by not returning a value) and then filtering our result for non null
values withwhereType<Food>()
.
The toJson
method is quite simple as we defined the toJson
method in the Food
class and can map our objects via the generated functions to JSON.
Summary
We now learned a simple way to convert abstract objects using a type field in our objects and JsonConverter
to map our objects to and from JSON.
If you want to check out a small dart project doing the conversion both ways you can visit my example on GitHub.
Thanks for reading and stay curious.