Dart Abstract JSON De-/Serialisation

Christian Navolskyi
arconsis
Published in
4 min readJan 13, 2023

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.

--

--