Gonçalo Palma
February 8, 2019

Creating Custom built_value Serializers

When using built_value for JSON serializing and deserializing, we might have some cases that go beyond the capabilities of StandardJsonPlugin. Imagine the following problem: you have an API endpoint that can give you two different types of data structures for the same value as shown in the following example:

{
	"value": "String value"
}
{
	"value": ["String 1", "String 2"]
}

In our flutter app, we might want to have an object that can have both values, and then we decide what we show in our Widgets.

class CustomValue {
	String singleValue;
	List<String> multipleValues;
}

So, how can we map the String value to singleValue and the array of Strings to multipleValues? With a CustomSerialize. If we inspect the Serializer class, it’s stated:

must extend either [PrimitiveSerializer] or/
[StructuredSerializer]./

Since our data structure is not a primitive object, we’ll have to create a class that implements StructuredSerializer.

class CustomValueSerializer
    implements StructuredSerializer<CustomValue> {
  @override
  CustomValue deserialize(Serializers serializers, Iterable serialized, {FullType specifiedType = FullType.unspecified}) {
    // TODO: implement deserialize
    return null;
  }

  @override
  Iterable serialize(Serializers serializers, CustomValue object, {FullType specifiedType = FullType.unspecified}) {
    // TODO: implement serialize
    return null;
  }

  @override
  // TODO: implement types
  Iterable<Type> get types => null;

  @override
  // TODO: implement wireName
  String get wireName => null;
}

Let’s inspect each method we need to implement: types are the types of objects that can be serialized. When using built_value, it will generate an internal type called _$CustomValue that must also be serialized, so we have:

@override
Iterable<Type> get types => [CustomValue, _$CustomValue];

wirenameis the name of the class we want to serialize

@override
String get wireName => "CustomValue";

Finally, we must implement the serialize and deserialize methods. Here we will be able to check if the value that we receive is of type String or type List and map it to the correct value of CustomValue. To do this, we need to check how the generated code of a similar class is structured, and adjust it to fit our needs. In this case, when we are checking the value field, we are not assigning it directly, but checking first which type to attribute to the variable, to either assign it to String value or List<String> values.

However, since we are using built_value, the type of list we will be dealing with comes from the built_collection package, hence we will be declaring it as BuiltList<String> values.

@override
CustomValue deserialize(Serializers serializers, Iterable serialized,
    {FullType specifiedType = FullType.unspecified}) {
  // Initialize an empty builder
  final result = new CustomValueBuilder();

  // Create an `Iterator` from the serialized data received
  final iterator = serialized.iterator;
  // Loop the iterator for each key
  while (iterator.moveNext()) {
    final key = iterator.current as String;
    iterator.moveNext();
    final dynamic value = iterator.current;
    // for each key, assign the correct value to the builder
    switch (key) {
      case 'value':
        // If the value is of type List<dynamic>, assign it to `values`
        if (value is List<dynamic>) {
          result.values.replace(serializers.deserialize(value,
              specifiedType: const FullType(BuiltList, const [
                const FullType(String)
              ])) as BuiltList);
        // else, the value is of type `String`
        } else {
          result.value = serializers.deserialize(value.toString(),
              specifiedType: const FullType(String)) as String;
        }
        break;
    }
  }

  return result.build();
}

@override
Iterable serialize(Serializers serializers, CustomValue object,
    {FullType specifiedType = FullType.unspecified}) {
  // Create an empty object array
  final result = <Object>[];
  // if the value of the `CustomValue` is not null, then assign it to a String
  if (object.value != null) {
    result
      ..add('value')
      ..add(serializers.serialize(object.value,
          specifiedType: const FullType(String)));
  }
  // Else, it means that we have a list. In this case the list will always override
  // the defined String value
  if (object.values != null) {
    result
      ..add('values')
      ..add(serializers.serialize(object.values,
          specifiedType:
              const FullType(BuiltList, const [const FullType(String)])));
  }

  return result;
}

Now, since we have our CustomValueSerializer class, we can start working on the CustomValue class.

part 'custom_value.g.dart';

abstract class CustomValue implements Built<CustomValue, CustomValueBuilder> {
  static Serializer<CustomValue> get serializer => null; // todo

  @nullable String get value;

  @nullable BuiltList<String> get values;

  CustomValue._();

  factory CustomValue([updates(CustomValueBuilder b)]) = _$CustomValue;
}

The setup of the class is equal to a class that uses the StandardJsonPlugin, the only difference being in the way we declare the serializer. In this case, we can use the new annotation @BuiltValueSerializer to say to the serializer “hey, we are using a custom serializer, don’t generate one for this class”.

@BuiltValueSerializer(custom: true)
static Serializer<CustomValue> get serializer => CustomDataSerializer();

What’s missing?

Our Serializers class that declares all the classes to be serialized in the project. For the case of custom serializers, we don’t need to put additional information in this class, so we can initialize it as we usually do.

part 'serializers.g.dart';

@SerializersFor(const [
  CustomValue
])

Serializers serializers = _$serializers;

Serializers standardSerializers =
(serializers.toBuilder()
  ..addPlugin(StandardJsonPlugin())
).build();

Finally, we can run build_runner in the terminal to generate all the new files:

flutter packages pub run build_runner watch

Et voilá! We have successfully used a custom serializer with built_value! 🎉

As a bonus, we can guarantee that everything is working by writing some good-old-fashioned unit tests:

test("Single value", () {
    var value = "test";
    var jsonMap = '{"value": "$value"}';

    var encodedJson = json.jsonDecode(jsonMap);

    CustomValue customValue = standardSerializers.deserializeWith(CustomValue.serializer, encodedJson);

    expect(customValue.value, equals(value));
    expect(customValue.values, isNull);
  });

test("Multiple values", () {
  var value1 = "test";
  var value2 = "system";
  var value = '["$value1", "$value2"]';
  var jsonMap = '{"value": $value}';

  var encodedJson = json.jsonDecode(jsonMap);

  CustomValue customValue = standardSerializers.deserializeWith(CustomValue.serializer, encodedJson);

  expect(customValue.value, isNull);
  expect(customValue.values, equals([value1, value2]));
});

All tests pass, and we are good to go.

You can see the full example in the GitHub Repo.

Follow me!

I often share some small insights on Flutter 💙