Custom Serializer with built_value in Dart and Flutter
Nikki Goel
Posted on February 21, 2023
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
.
Overview
- Application
- Setting up Models
- What's the problem?
- Finally, a solution!
- This solution works. Why?
- Simplifying the solution
- Going one step further
- Conclusion
- Final Note
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
andbuildFactory
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.
Posted on February 21, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.