How to Keep Generics While Serializing
Closed this issue · 6 comments
I cannot figure out how to get the correct type of a class when it is deserialized from JSON.
I have a class Location
that has two subclasses as seen below:
// location.dart
@MappableClass(discriminatorKey: 'latitude')
abstract class Location with LocationMappable {
const Location();
factory Location.fromJson(Map<String, dynamic> json) =>
LocationMapper.fromJson(json);
}
@MappableClass(discriminatorValue: MappableClass.useAsDefault)
class PointLocation extends Location with PointLocationMappable {
final double latitude;
final double longitude;
const PointLocation({required this.latitude, required this.longitude});
factory PointLocation.fromJson(Map<String, dynamic> json) =>
PointLocationMapper.fromJson(json);
}
@MappableClass(discriminatorValue: null)
class PolygonLocation extends Location with PolygonLocationMappable {
// a custom mapper exists for LatLng
final IList<LatLng> points;
const PolygonLocation({required this.points});
factory PolygonLocation.fromJson(Map<String, dynamic> json) =>
PolygonLocationMapper.fromJson(json);
}
Now I have another class that utilizes Location
as a generic parameter as seen below:
// artifact.dart
@MappableClass(discriminatorKey: 'id')
class Artifact<T extends Location> with ArtifactMappable<T> {
final T location;
const Artifact({
required this.title,
});
factory Artifact.fromJson(Map<String, dynamic> json) =>
ArtifactMapper.fromJson(json);
}
@MappableClass(discriminatorValue: MappableClass.useAsDefault)
class ServerArtifact<T extends Location> extends Artifact<T>
with ServerArtifactMappable<T> {
final String id;
const ServerArtifact({
required this.id,
required super.location
});
@override
factory ServerArtifact.fromJson(Map<String, dynamic> json) =>
ServerArtifactMapper.fromJson(json);
@override
String toString() {
return 'Type: $T.';
}
}
@MappableClass(discriminatorValue: null)
class NewArtifact<T extends Location> extends Artifact<T>
with NewArtifactMappable<T> {
const NewArtifact({
required super.location,
});
@override
factory NewArtifact.fromJson(Map<String, dynamic> json) =>
NewArtifact.fromJson(json);
}
Note: I am using
fromJson
instead offromMap
as seen in the docs.
Now, whenever I am using serializing an Artifact
from JSON it correctly sets the location
property to be the correct location (i.e. PointLocation
or PolygonLocation
) however the generic type of Artifact
is always Location
not PointLocation
even if location field is a PointLocation
class as shown in the toString
of ServerArtifact
the type of T
is Location
.
I can't just do MapperContainer.globals.fromJson<Artifact<PointLocation>>(...);
because typically Artifact
is being deserialized from a list where each Artifact
could have a PointLocation
or a PolygonLocation
type parameter.
I see in the docs that it is recommended to have a type field in the JSON, however I cannot control the JSON response from the server so cannot add that type field, and I thought that by adding a discriminator the generator would be able to figure it out.
Not sure if this is a bug or (more likely :) I'm doing something wrong so any help is appreciated!
I have for sure never seen discriminatorKey
being used like this, but at the same time I don't see anything wrong with it from just now.
Can you add a snippet on how you call the fromJson method and what you expect + actually get as a result.
Thanks for responding :) I'm calling the fromJson
from another class. Here is a simplified version of that class:
@MappableClass(discriminatorValue: ServerTour.checkType)
class ServerTour extends Tour with ServerTourMappable {
final IList<ServerArtifact> artifacts;
const ServerTour({
required super.id,
required this.artifacts,
});
static bool checkType(value) {
return value is Map && value['businessId'] != null;
}
factory ServerTour.fromJson(Map<String, dynamic> json) =>
ServerTourMapper.fromJson(json);
}
Then I call ServerTour.fromJson(...json)
.
What is printed is:
ServerTour(id: 6f263192-ac3c-4da1-a88e-1a3eec232d4e, artifacts: [
Type: Location. ServerArtifact(location: PointLocation(.... other data)),
Type: Location. ServerArtifact(location: PointLocation(.... other data)),
)
Currently my workaround I've found is to create a changeType method in Artifact
where I manually reconstruct the Artifact
using a MappingHook in my Tour
class like the following:
// in my artifact.dart class
@mustBeOverridden
Artifact<G> changeType<G extends Location>() {
return Artifact(
title: title,
description: description,
location: location as G,
);
}
Then I add the following MappingHook to my IList property in Tour
where the JSON serialization occurs:
class ArtifactsHook extends MappingHook {
const ArtifactsHook();
@override
Object? afterDecode(Object? value) {
if (value is IList<ServerArtifact>) {
return value.map((artifact) {
if (artifact.location is PointLocation) {
return artifact.changeType<PointLocation>();
} else {
return artifact.changeType<PolygonLocation>();
}
}).toIList();
}
return null;
}
}
While this works for now, I hope this is not the long-term solution as everytime I change a property in Artifact
I would now have to change each changeType
property as well.
Ok I got your problem now.
However this issue is not really a bug and works as supposed to. The reason is because deserialization works in a top-down manner for choosing the type in nested class structures. So the generic type for Artifact is chosen before Location is decoded, therefore it does not know the exact type of Location yet. I generally think this behavior is correct as the top class may also want to have other values for the generic type.
So for your case I see two solutions. Either use a hook like you did to modify the class after decoding, or reverse the decoding order for location to force a correct decoding of the generic type.
Here is a hook for the 2nd option to apply to Artifact:
class ArtifactHook extends MappingHook {
const ArtifactHook();
@override
Object? beforeDecode(Object? value) {
if (value is Map<String, dynamic>) {
if (value['location'] case Map<String, dynamic> l) {
var location = LocationMapper.fromMap(l);
return ArtifactMapper.ensureInitialized().decoder(
{...value, 'location': location},
DecodingContext(args: () => [location.runtimeType]),
);
}
}
return value;
}
}
This swaps the normal order around by first decoding the Location, and then using its type as generic type argument for decoding the Artifact. Also here I pass an updated map to ArtifactMapper to skip decoding the Location twice.
Thanks for the second hook. That is a much better option. Had to change it a little bit because it was a list but overall pleasantly surprised by the flexibility that dart_mappable provides compared with alternatives. Thanks for all your work on this package!