Polymorphism topic

A common pattern that you might want to use for your classes is polymorphism. As a simple example see the classes below.

abstract class Animal {
  String name;
  
  Animal(this.name);
}

class Cat extends Animal {
  String color;
  
  Cat(String name, this.color) : super(name);
}

class Dog extends Animal {
  int age;
  
  Dog(String name, this.age) : super(name);
}

Here the abstract Animal class can either be a Cat or a Dog which can inherit some properties but also introduce new ones. Now consider the following class:

class Home {
  Animal pet;
  
  Home(this.pet);
}

Now when we want to encode a Home object, the pet property can either be a Cat or a Dog. Here two problems arise:

  • When deserializing, we need to make sure that the correct subclass is chosen and instantiated, and
  • when serializing, we need to make sure that the subclass information isn't lost.

To solve these, we need to add a discriminator property, that keeps track of the specific (sub)-type of the pet.

By default no discriminator is applied, but you can change this by setting the discriminatorKey annotation property (or globally in the build configuration).

The value of this property will default to the name of the class, but you can change this as well with the discriminatorValue annotation property.

To reiterate: Make sure to specify the discriminatorKey property globally or on the base class - Animal in our example - and the discriminatorValue property on each of the child classes - Cat and Dog in our case.

// Set the discriminator key to "type".
@MappableClass(discriminatorKey: 'type')
abstract class Animal with AnimalMappable {
  ...
}

// Set the discriminator value for Cat to "kitty".
@MappableClass(discriminatorValue: 'kitty')
class Cat extends Animal with CatMappable {
  ...
}

// Here the other sub-class Dog will have the default discriminator value "Dog".

void main() {
  // encode a polymorphic class
  String catJson = Cat('Judy', 'black').toJson();
  print(catJson); // {"name":"Judy","color":"black","type":"kitty"}

  Animal myPet = AnimalMapper.fromJson(catJson); // implicit serialization as an 'Animal'
  print(myPet.runtimeType); // has correct runtime type 'Cat'

  // explicit serialization also works as usual without a discriminator
  Cat myCat = CatMapper.fromJson('{"name": "Mindy", "color": "Brown"}');
  print(myCat.name); // Mindy
}

Include Subclasses

The only slight downside of this system is that all sub-classes must be known statically at compile-time. dart_mappable handles this in two ways:

  1. All sub-classes defined in the same library as the parent class will be automatically included.
  2. All other sub-classes must be explicitly specified using the includeSubClasses property of the @MappableClass() annotation:
// Suppose [Hamster] is defined in some other library than [Animal]
@MappableClass(discriminatorKey: 'type', includeSubClasses: [Hamster])
abstract class Animal with AnimalMappable {
  ...
}

Null and Default Values

There are two additional cases that you might want to cover. Either your discriminator property is null, or some other value that you did not specify.

  1. For the null case, you can explicitly set the discriminatorValue property to null and this will work as expected.
  2. For the other case, you can specify a fallback class to be used whenever we come across an unknown discriminator value. On this class set the discriminatorValue to MappableClass.useAsDefault.
// This sub-class will be chosen when the discriminator is null.
@MappableClass(discriminatorValue: null)
class NullAnimal extends Animal with NullAnimalMappable {
  NullAnimal(String name) : super(name);
}

// This sub-class will be chosen on any unknown discriminator.
@MappableClass(discriminatorValue: MappableClass.useAsDefault)
class DefaultAnimal extends Animal with DefaultAnimalMappable {
  String type;
  DefaultAnimal(String name, this.type) : super(name);
}

void main() {
  // decode json with the discriminator set to null (same as missing property)
  Animal animal1 = AnimalMapper.fromJson('{"name": "Scar", "type": null}');
  print(animal1.runtimeType); // NullAnimal

  // decode json with unknown discriminator value
  Animal animal2 = AnimalMapper.fromJson('{"name": "Balu", "type": "Bear"}');
  print(animal2.runtimeType); // DefaultAnimal
  print(animal2.type); // Bear
}

Custom Discriminator Logic

For a more advanced use-case where the discriminator key/value system is not enough, you can use the CheckTypesHook to define custom discriminator checks on your subclasses.

Instead of giving each subclass a discriminator value, each subclass can have a custom function that checks whether the encoded value should be decoded to this subclass and returns a boolean.

@MappableClass(
  hooks: CheckTypesHook({
    B: B.checkType,
    C: C.checkType,
  }),
)
abstract class A with AMappable {
  A();
}

@MappableClass()
class B extends A with BMappable {
  B();
  
  /// checks if [value] should be decoded to [B]
  static bool checkType(value) {
    return value is Map && value['isB'] == true;
  }
}

@MappableClass()
class C extends A with CMappable {
  C();
  
  /// checks if [value] should be decoded to [C]
  static bool checkType(value) {
    return value is Map && value['isWhat'] == 'C';
  }
}

Next: Generics

Classes

CheckTypesHook Polymorphism Mapping Hooks
A MappingHook that allows to specify custom type checks for serialization polymorph subclasses of a class.
MappableClass Introduction Models Configuration Polymorphism Mapping Hooks Custom Mappers
Used to annotate a class in order to generate mapping code
MappableLib Configuration Polymorphism Generics
Used to annotate a library to define default values.