Custom Serializer with built_value in Dart and Flutter

Custom Serializer with built_value in Dart and Flutter

Deserialization of a collection with generic type with built_value in Dart and Flutter

ยท

9 min read

Introduction

The package, built_value is used for creating immutable classes in Dart with JSON serialization. It's a great package and uses build_runner to create generated files to avoid boilerplate code.
But if you've ever tried to deserialize a model class with a collection generic type like BuiltList, it'll drive you mad!

So, here is our goal. We're going to customize build_value's serializer such that it can deserialize properties like BuiltList<T> get data.

Here's the GitHub repo with all the code.

Application

The inspiration for this is a straightforward use case:
Creating a generic pagination model with the BuiltList<T> get data property.
So all paginated APIs can use this one model with a different data type for the data field. Rest of the fields like current_page, next_page, etc. are common for all paginated APIs.

Setting up Models

Create a file called generic_model.dart. Using the snippets bvtgsf from the built_value_snippets package in VS Code (or any other IDE), we get the following class.
I've made some changes to it as required.

/// Add the import for the serializer
import 'serializers.dart';

part 'generic_model.g.dart';

abstract class GenericModel<T>
    implements Built<GenericModel<T>, GenericModelBuilder<T>> {
  GenericModel._();
  factory GenericModel([void Function(GenericModelBuilder<T>) updates]) =
      _$GenericModel<T>;

  Map<String, dynamic> toJson() {
    /// Add the typecast [Map<String, dynamic>] to fix the error
    return serializers.serializeWith(GenericModel.serializer, this)
        as Map<String, dynamic>;
  }

  /// Add [<T>] to deserialize generic type values
  static GenericModel<T> fromJson<T>(Map<String, dynamic> json) {
    /// Add the typecast [GenericModel<T>] to fix the error
    return serializers.deserializeWith(GenericModel.serializer, json)
        as GenericModel<T>;
  }

  static Serializer<GenericModel> get serializer => _$genericModelSerializer;

  BuiltList<T> get data;
}

Let's complete the serializer class setup.

part 'serializers.g.dart';

@SerializersFor([
  GenericModel,
])
final Serializers serializers =
    (_$serializers.toBuilder()..addPlugin(StandardJsonPlugin())).build();

And last, let's generate the code by using the following command.
dart run build_runner build or
flutter pub run build_runner build if you have a Flutter project.

If you get any errors with this command, try it with the following parameters:
dart run build_runner build --delete-conflicting-outputs

And we're done with the initial setup.

What's the problem?

Now we'll test the serialization and deserialization with the generic type String. We'll create the class with a list of strings and try to serialize it.

void main() {
  /// Testing serialization generic type [String]
  final testModel = GenericModel<String>(
    (b) => b
      ..data = ListBuilder<String>(
        ['string 1', 'string 2'],
      ),
  );

  print('model: $testModel');
  print('serialized json: ${testModel.toJson()}');
}

Output:

model: GenericModel {
  data=[string 1, string 2],
}
serialized json: {data: [{$: String, : string 1}, {$: String, : string 2}]}

So this looks good. It worked.
But wait, we have to test deserialization as well. This won't work but don't go on my word. I'll show you.

void main() {
  /// Testing serialization with generic type [String]
  final testModel = GenericModel<String>(
    (b) => b
      ..data = ListBuilder<String>(
        ['string 1', 'string 2'],
      ),
  );

  print('model: $testModel');
  print('serialized json: ${testModel.toJson()}');

  /// Testing deserialization with generic type [String]
  final dummyJson = {
    'data': ['string 1', 'string 2'],
  };
  try {
    final testString = GenericModel.fromJson<String>(dummyJson);

    print(testString);
    print(testString.toJson());
  } catch (e) {
    print("Looks like it didn't work ๐Ÿ™ƒ");
    print(e);
  }
}

Output:

model: GenericModel {
  data=[string 1, string 2],
}
serialized json: {data: [{$: String, : string 1}, {$: String, : string 2}]}
Looks like it didn't work ๐Ÿ™ƒ
Deserializing '[data, [string 1, string 2]]' to 'GenericModel' failed due to: Deserializing '[string 1, string 2]' to 'BuiltList<Object>' failed due to: Bad state: No serializer for 'Object'.

The deserialization doesn't work. It's because built_value cannot detect the data type of the object to be created.
Hence we have to tell it explicitly.

Finally, a solution!

There are two things we need to add to fix this.
First, the specifiedType and second, the addBuilderFactory.

  1. Adding specifiedType

In GenericModel class, we will explicitly tell the serializer to deserialize the T type.

abstract class GenericModel<T>
    implements Built<GenericModel<T>, GenericModelBuilder<T>> {
  GenericModel._();
 ...

  static GenericModel<T> fromJson<T>(Map<String, dynamic> json) {
/// <-------- Added here
    return serializers.deserialize(
      json,
      specifiedType: FullType(GenericModel, [FullType(T)]),
    ) as GenericModel<T>;
/// <-------- Added here
  }

  static Serializer<GenericModel> get serializer => _$genericModelSerializer;

  BuiltList<T> get data;
}

Before moving to step 2, let's run the code.

model: GenericModel {
  data=[string 1, string 2],
}
serialized json: {data: [{$: String, : string 1}, {$: String, : string 2}]}

Looks like it didn't work ๐Ÿ™ƒ
Deserializing '[data, [string 1, string 2]]' to 'GenericModel<String>' failed due to: Bad state: No builder factory for GenericModel<String>. Fix by adding one, see SerializersBuilder.addBuilderFactory.

Progress, we have a different error now.
The error mentions addBuilderFactory, the same thing that we need for step 2.

  1. Adding addBuilderFactory

This will be added to the serializer.dart class.

@SerializersFor([
  GenericModel,
])
final Serializers serializers = (_$serializers.toBuilder()
      ..addPlugin(StandardJsonPlugin())
/// <-------- Added here
      ..addBuilderFactory(
        const FullType(GenericModel, [FullType(String)]),
        () => GenericModelBuilder<String>(),
      )
      ..addBuilderFactory(
        const FullType(BuiltList, [FullType(String)]),
        () => ListBuilder<String>(),
      ))
/// <-------- Added here
    .build();

Now, if we run the code everything works. Yay! ๐ŸŽ‰

model: GenericModel {
  data=[string 1, string 2],
}
serialized json: {data: [{$: String, : string 1}, {$: String, : string 2}]}
GenericModel {
  data=[string 1, string 2],
}
{data: [{$: String, : string 1}, {$: String, : string 2}]}

This solution works. Why?

built_value doesn't add a builder and specifiedType for generic values.
It is up to the developers how they want to define it.

In the test file of the built_value package, they have defined such a case here.

/// code snippet from the generics_serializer_test.dart file at line 38
group('GenericValue with known specifiedType and correct builder', () {
    var data = GenericValue<int>((b) => b..value = 1);
    var specifiedType = const FullType(GenericValue, [FullType(int)]);

   /// defining a different serializer
    var serializersWithBuilder = (serializers.toBuilder()
          ..addBuilderFactory(specifiedType, () => GenericValueBuilder<int>()))
        .build();
    var serialized = json.decode(json.encode([
      'value',
      1,
    ])) as Object;

    test('can be serialized', () {
      expect(
          serializersWithBuilder.serialize(data, specifiedType: specifiedType),
          serialized);
    });

    test('can be deserialized', () {   /// <--- using different serializer
      expect(
          serializersWithBuilder.deserialize(serialized,
              specifiedType: specifiedType),
          data);
    });

The deserialization is done using a custom serializer object which contains a new builder and a specified type.

If we were to use generic value with collections (like BuiltList) we need another builder. This is mentioned in this test.

/// code snippet from the generics_serializer_test.dart file at line 197
group('CollectionGenericValue with known specifiedType and correct builder',
      () {
    var data = CollectionGenericValue<int>((b) => b..values.add(1));
    var specifiedType = const FullType(CollectionGenericValue, [FullType(int)]);
    var serializersWithBuilder = (serializers.toBuilder()
          ..addBuilderFactory(
              specifiedType, () => CollectionGenericValueBuilder<int>())

          /// adding second builder for collection
          ..addBuilderFactory(const FullType(BuiltList, [FullType(int)]),
              () => ListBuilder<int>()))
        .build();
...

In addition to a custom serializer, we add another builderFactory for the BuiltList collection.

We can of course create as many customizations now as needed by adding specifiedType and builderFactory methods.

Refer the test file here for more examples.

Simplifying the solution

Is this the end?
It depends.
We have found the solution but imagine you have to use the GenericModel class with not just one but multiple object types. We could copy-paste the builder factory to the serializer.dart class, but that's a bad idea.
Let me show you why.

final Serializers serializers = (_$serializers.toBuilder()
      ..addPlugin(StandardJsonPlugin())

      /// for [String]
      ..addBuilderFactory(
        const FullType(GenericModel, [FullType(String)]),
        () => GenericModelBuilder<String>(),
      )
      ..addBuilderFactory(
        const FullType(BuiltList, [FullType(String)]),
        () => ListBuilder<String>(),
      )

      /// for [int]
      ..addBuilderFactory(
        const FullType(GenericModel, [FullType(int)]),
        () => GenericModelBuilder<int>(),
      )
      ..addBuilderFactory(
        const FullType(BuiltList, [FullType(int)]),
        () => ListBuilder<int>(),
      )

    /// and so on...

Manually adding and removing them can introduce various bugs. Plus we're lazy and don't want to remember updating this.
Hence, we do what we do best -> automate it.

Automate is just a buzz keyword here, we're just going to abstract adding builderfactory for GenericModel.

We'll add a new class to the serializers.dart file which adds the builder factories to serializer object.

@SerializersFor([
  GenericModel,
])

/// Making this private and mutable
Serializers _serializers =
    (_$_serializers.toBuilder()..addPlugin(StandardJsonPlugin())).build();

final serializers = _serializers;

/// Class to add new factories according to the type
class GenericBuilderFactory<T> {
  /// Keeping track of the builder factories added to the serializers for type T
  static final Map<Type, GenericBuilderFactory> _factories = {};

  /// returning serializer if the builder factory is already added
  static Serializers getSerializer<T>() {
    if (_factories.containsKey(T)) {
      return _serializers;
    }
    return GenericBuilderFactory<T>()._addFactory();
  }

  /// Adding the builder factory
  Serializers _addFactory() {
    _factories[T] = this;

    _serializers = (_serializers.toBuilder()
          ..addBuilderFactory(
            FullType(GenericModel, [FullType(T)]),
            () => GenericModelBuilder<T>(),
          )
          ..addBuilderFactory(
            FullType(BuiltList, [FullType(T)]),
            () => ListBuilder<T>(),
          ))
        .build();

    return _serializers;
  }
}

Next, we call this static method to access serializer object in our GenericModel class.

...
  static GenericModel<T> fromJson<T>(Map<String, dynamic> json) {
    return GenericBuilderFactory.getSerializer<T>().deserialize(
      json,
      specifiedType: FullType(GenericModel, [FullType(T)]),
    ) as GenericModel<T>;
  }
...

We made a lot of changes, let's make sure our old tests are still passing.
Run the same builder_runner command and run the project with the dart run command.

Output:

GenericModel {
  data=[string 1, string 2],
}
{data: [{$: String, : string 1}, {$: String, : string 2}]}

Phew! The old code is working.
Let's try adding a test case for int type to make sure it's working.

...
/// Testing deserialization with generic type [String]
  final dummyJson = {
    'data': ['string 1', 'string 2'],
  };
  try {
    final testString = GenericModel.fromJson<String>(dummyJson);

    print(testString);
    print(testString.toJson());
  } catch (e) {
    print("\nLooks like it didn't work ๐Ÿ™ƒ");
    print(e);
  }

  /// Testing deserialization with generic type [int]
  final dummyJsonInt = {
    'data': [1, 2],
  };
  try {
    final testInt = GenericModel.fromJson<int>(dummyJsonInt);

    print(testInt);
    print(testInt.toJson());
  } catch (e) {
    print("\nLooks like it didn't work ๐Ÿ™ƒ");
    print(e);
  }
...

Output:

model: GenericModel {
  data=[string 1, string 2],
}
serialized json: {data: [{$: String, : string 1}, {$: String, : string 2}]}
GenericModel {
  data=[string 1, string 2],
}
{data: [{$: String, : string 1}, {$: String, : string 2}]}
GenericModel {
  data=[1, 2],
}
{data: [{$: int, : 1}, {$: int, : 2}]}

It worked!
So now we don't need to add the builder factory manually in the serializer object. This one-time setup is all we need.

Going one step further

We've tested our GenericModel with int and String data types. Now let's try it with a user-defined type, a new model called MyModel.

We'll create a new model with two properties: name and id.

part 'my_model.g.dart';

abstract class MyModel<T> implements Built<MyModel<T>, MyModelBuilder<T>> {
  MyModel._();
  factory MyModel([void Function(MyModelBuilder<T>) updates]) = _$MyModel<T>;

  Map<String, dynamic> toJson() {
    return serializers.serializeWith(MyModel.serializer, this)
        as Map<String, dynamic>;
  }

  static MyModel fromJson(Map<String, dynamic> json) {
    return serializers.deserializeWith(MyModel.serializer, json) as MyModel;
  }

  static Serializer<MyModel> get serializer => _$myModelSerializer;

  String get name;

  String get id;
}

Adding this model in the serializer.dart file.

...
part 'serializers.g.dart';

@SerializersFor([
  GenericModel,
  MyModel, // <--- Adding new model here
])
...

Now, run the dart run build_runner build command. Now that the code is generated and the errors are gone, let's test our new model.

...
  /// Testing deserialization with generic type [MyModel]
  final dummyJsonMyModel = {
    'data': [
      {'name': 'name 1', 'id': 'id 1'},
      {'name': 'name 2', 'id': 'id 2'},
    ],
  };

  final testMyModel = GenericModel.fromJson<MyModel>(dummyJsonMyModel);

  print('deserialized model: $testMyModel');

  /// Testing serialization with generic type [MyModel]
  print('serialized json: ${testMyModel.toJson()}');
...

Output:

deserialized model: GenericModel {
  data=[MyModel {
    name=name 1,
    id=id 1,
  }, MyModel {
    name=name 2,
    id=id 2,
  }],
}
serialized json: {data: [{$: MyModel, name: name 1, id: id 1}, {$: MyModel, name: name 2, id: id 2}]}

It works. We've successfully created a custom deserializer for a collection generic type model in build_value.
We also abstracted the addition of the builder factory for each generic type.


Conclusion

  • We have to create a custom serializer to deserialize the collection of generic data-type objects.

  • specifiedType and buildFactory methods have to be added for all the generic types.

  • The solution can be simplified by adding the above dependencies in the code instead of doing it manually.

Final Note

Thank you for reading this article. If you enjoyed it, consider sharing it with other people.
If you find any mistakes, please let me know.
Feel free to share your opinions below.

Did you find this article valuable?

Support Nikki Goel by becoming a sponsor. Any amount is appreciated!

ย