json_serializable
json_serializable has a several strategies1 to handle generic types as single objects T
or List<T>
(as of v. 5.0.2+) :
- Helper Class:
JsonConverter
- Helper Methods:
@JsonKey(fromJson:, toJson:)
- Generic Argument Factories
@JsonSerializable(genericArgumentFactories: true)
1 Of which I'm aware. There's likely other ways to do this.
Helper Class: JsonConverter
Basic idea: write a custom JsonConverter
class with fromJson
& toJson
methods to identify & handle our Type T
field de/serialization.
The nice thing about the JsonCoverter
strategy is it encapsulates all your de/serialization logic for your models into a single class that's reusable across any classes needing serialization of the same model types. And your toJson
, fromJson
calls don't change, as opposed to Generic Argument Factories strategy, where every toJson
, fromJson
call requires we supply a handler function.
We can use JsonConverter
with our object to de/serialize by annotating:
- individual
T
/ List<T>
fields requiring custom handling, or
- the entire class (where it will be used on any/all fields of type
T
).
Below is an example of a json_serializable class OperationResult<T>
containing a generic type field T
.
Notes on OperationResult
class:
- has a single generic type field
T t
.
t
can be a single object of type T
or a List<T>
of objects.
- whatever type
T
is, it must have toJson()/fromJson()
methods (i.e. be de/serializable).
- has a
JsonConverter
class named ModelConverter
annotating the T t
field.
- generated stubs
_$OperationResultFromJson<T>(json)
& _$OperationResultToJson<T>()
now take a T
variable
/// This method of json_serializable handles generic type arguments / fields by
/// specifying a converter helper class on the generic type field or on the entire class.
/// If the converter is specified on the class itself vs. just a field, any field with
/// type T will be de/serialized using the converter.
/// This strategy also requires us determine the JSON type during deserialization manually,
/// by peeking at the JSON and making assumptions about its class.
@JsonSerializable(explicitToJson: true)
class OperationResult<T> {
final bool ok;
final Operation op;
@ModelConverter()
final T t;
final String title;
final String msg;
final String error;
OperationResult({
this.ok = false,
this.op = Operation.update,
required this.t,
this.title = 'Operation Error',
this.msg = 'Operation failed to complete',
this.error= 'Operation could not be decoded for processing'});
factory OperationResult.fromJson(Map<String,dynamic> json) =>
_$OperationResultFromJson<T>(json);
Map<String,dynamic> toJson() => _$OperationResultToJson<T>(this);
}
And here is the JsonConverter
class ModelConverter
for the above:
/// This JsonConverter class holds the toJson/fromJson logic for generic type
/// fields in our Object that will be de/serialized.
/// This keeps our Object class clean, separating out the converter logic.
///
/// JsonConverter takes two type variables: <T,S>.
///
/// Inside our JsonConverter, T and S are used like so:
///
/// T fromJson(S)
/// S toJson(T)
///
/// T is the concrete class type we're expecting out of fromJson() calls.
/// It's also the concrete type we're inputting for serialization in toJson() calls.
///
/// Most commonly, T will just be T: a variable type passed to JsonConverter in our
/// Object being serialized, e.g. the "T" from OperationResult<T> above.
///
/// S is the JSON type. Most commonly this would Map<String,dynamic>
/// if we're only de/serializing single objects. But, if we want to de/serialize
/// Lists, we need to use "Object" instead to handle both a single object OR a List of objects.
class ModelConverter<T> implements JsonConverter<T, Object> {
const ModelConverter();
/// fromJson takes Object instead of Map<String,dynamic> so as to handle both
/// a JSON map or a List of JSON maps. If List is not used, you could specify
/// Map<String,dynamic> as the S type variable and use it as
/// the json argument type for fromJson() & return type of toJson().
/// S can be any Dart supported JSON type
/// https://pub.dev/packages/json_serializable/versions/6.0.0#supported-types
/// In this example we only care about Object and List<Object> serialization
@override
T fromJson(Object json) {
/// start by checking if json is just a single JSON map, not a List
if (json is Map<String,dynamic>) {
/// now do our custom "inspection" of the JSON map, looking at key names
/// to figure out the type of T t. The keys in our JSON will
/// correspond to fields of the object that was serialized.
if (json.containsKey('items') && json.containsKey('customer')) {
/// In this case, our JSON contains both an 'items' key/value pair
/// and a 'customer' key/value pair, which I know only our Order model class
/// has as fields. So, this JSON map is an Order object that was serialized
/// via toJson(). Now I'll deserialize it using Order's fromJson():
return Order.fromJson(json) as T;
/// We must cast this "as T" because the return type of the enclosing
/// fromJson(Object? json) call is "T" and at compile time, we don't know
/// this is an Order. Without this seemingly useless cast, a compile time
/// error will be thrown: we can't return an Order for a method that
/// returns "T".
}
/// Handle all the potential T types with as many if/then checks as needed.
if (json.containsKey('status') && json.containsKey('menuItem')) {
return OrderItem.fromJson(json) as T;
}
if (json.containsKey('name') && json.containsKey('restaurantId')) {
return Menu.fromJson(json) as T;
}
if (json.containsKey('menuId') && json.containsKey('restaurantId')) {
return MenuItem.fromJson(json) as T;
}
} else if (json is List) { /// here we handle Lists of JSON maps
if (json.isEmpty) return [] as T;
/// Inspect the first element of the List of JSON to determine its Type
Map<String,dynamic> _first = json.first as Map<String,dynamic>;
bool _isOrderItem = _first.containsKey('status') && _first.containsKey('menuItem');
if (_isOrderItem) {
return json.map((_json) => OrderItem.fromJson(_json)).toList() as T;
}
bool _isMenuItem = _first.containsKey('menuId') && _first.containsKey('restaurantId');
if (_isMenuItem) {
return json.map((_json) => MenuItem.fromJson(_json)).toList() as T;
}
}
/// We didn't recognize this JSON map as one of our model classes, throw an error
/// so we can add the missing case
throw ArgumentError.value(json, 'json', 'OperationResult._fromJson cannot handle'
' this JSON payload. Please add a handler to _fromJson.');
}
/// Since we want to handle both JSON and List of JSON in our toJson(),
/// our output Type will be Object.
/// Otherwise, Map<String,dynamic> would be OK as our S type / return type.
///
/// Below, "Serializable" is an abstract class / interface we created to allow
/// us to check if a concrete class of type T has a "toJson()" method. See
/// next section further below for the definition of Serializable.
/// Maybe there's a better way to do this?
///
/// Our JsonConverter uses a type variable of T, rather than "T extends Serializable",
/// since if T is a List, it won't have a toJson() method and it's not a class
/// under our control.
/// Thus, we impose no narrower scope so as to handle both cases: an object that
/// has a toJson() method, or a List of such objects.
@override
Object toJson(T object) {
/// First we'll check if object is Serializable.
/// Testing for Serializable type (our custom interface of a class signature
/// that has a toJson() method) allows us to call toJson() directly on it.
if (object is Serializable){
return object.toJson();
} /// otherwise, check if it's a List & not empty & elements are Serializable
else if (object is List) {
if (object.isEmpty) return [];
if (object.first is Serializable) {
return object.map((t) => t.toJson()).toList();
}
}
/// It's not a List & it's not Serializable, this is a design issue
throw ArgumentError.value(object, 'Cannot serialize to JSON',
'OperationResult._toJson this object or List either is not '
'Serializable or is unrecognized.');
}
}
Below is the Serializable
interface used for our model classes like Order
and MenuItem
to implement (see the toJson()
code of ModelConverter
above to see how/why this is used):
/// Interface for classes to implement and be "is" test-able and "as" cast-able
abstract class Serializable {
Map<String,dynamic> toJson();
}
Helper Methods: @JsonKey(fromJson:, toJson:)
This annotation is used to specify custom de/serialization handlers for any type of field in a class using json_serializable, not just generic types.
Thus, we can specify custom handlers for our generic type field T t
, using the same "peek at keys" logic as we used above in the JsonConverter example.
Below, we've added two static methods to our class OperationResultJsonKey<T>
(named this way just for obviousness in this Stackoverflow example):
(These can also live outside the class as top-level functions.)
Then we supply these two methods to JsonKey:
@JsonKey(fromJson: _fromJson, toJson: _toJson)
Then, after re-running our build_runner for flutter or dart (flutter pub run build_runner build
or dart run build_runner build
), these two static methods will be used by the generated de/serialize methods provided by json_serializable.
/// This method of json_serializable handles generic type arguments / fields by
/// specifying a static or top-level helper method on the field itself.
/// json_serializable will call these hand-typed helpers when de/serializing that particular
/// field.
/// During de/serialization we'll again determine the type manually, by peeking at the
/// JSON keys and making assumptions about its class.
@JsonSerializable(explicitToJson: true)
class OperationResultJsonKey<T> {
final bool ok;
final Operation op;
@JsonKey(fromJson: _fromJson, toJson: _toJson)
final T t;
final String title;
final String msg;
final String error;
OperationResultJsonKey({
this.ok = false,
this.op = Operation.update,
required this.t,
this.title = 'Operation Error',
this.msg = 'Operation failed to complete',
this.error = 'Operation could not be decoded for processing'});
static T _fromJson<T>(Object json) {
// same logic as JsonConverter example
}
static Object _toJson<T>(T object) {
// same logic as JsonConverter example
}
/// These two _$ methods will be created by json_serializable and will call the above
/// static methods `_fromJson` and `_toJson`.
factory OperationResultJsonKey.fromJson(Map<String, dynamic> json) =>
_$OperationResultJsonKeyFromJson(json);
Map<String, dynamic> toJson() => _$OperationResultJsonKeyToJson(this);
}
Generic Argument Factories @JsonSerializable(genericArgumentFactories: true)
In this final way of specialized handling for de/serialization, we're expected to provide custom de/serialization methods directly to our calls to toJson()
and fromJson()
on OperationResult
.
This strategy is perhaps the most flexible (allowing you to specify exactly how you want serialization handled for each generic type), but it's also very verbose requiring you to provide a serialization handler function on each & every toJson
/ fromJson
call. This gets old really quickly.
toJson
For example, when serializing OperationResult<Order>
, the .toJson()
call takes a function which tells json_serializable how to serialize the Order
field when serializing OperationResult<Order>
.
The signature of that helper function would be:
Object Function(T) toJsonT
So in OperationResult
our toJson()
stub method (that json_serializable completes for us) goes from:
Map<String,dynamic> toJson() => _$OperationResultToJson(this);
to:
Map<String,dynamic> toJson(Object Function(T) toJsonT) => _$OperationResultToJson<T>(this, toJsonT);
toJson()
goes from taking zero arguments, to taking a function as an argument
- that function will be called by json_serializable when serializing
Order
- that function returns
Object
instead of Map<String,dynamic>
so that it can also handle multiple T
objects in a List
such as List<OrderItem>
fromJson
For the fromJson()
side of genericArgumentFactories
used on our OperationResult<Order>
class expects us to provide a function of signature:
T Function(Object?) fromJsonT
So if our object with a generic type to de/serialize was OperationResult<Order>
, our helper function for fromJson()
would be:
static Order fromJsonModel(Object? json) => Order.fromJson(json as Map<String,dynamic>);
Here's an example class named OperationResultGAF
using genericArgumentFactories
:
@JsonSerializable(explicitToJson: true, genericArgumentFactories: true)
class OperationResultGAF<T> {
final bool ok;
final Operation op;
final String title;
final String msg;
final T t;
final String error;
OperationResultGAF({
this.ok = false,
this.op = Operation.update,
this.title = 'Operation Error',
this.msg = 'Operation failed to complete',
required this.t,
this.error= 'Operation could not be decoded for processing'});
// Interesting bits here → ----------------------------------- ↓ ↓
factory OperationResultGAF.fromJson(Map<String,dynamic> json, T Function(Object? json) fromJsonT) =>
_$OperationResultGAFFromJson<T>(json, fromJsonT);
// And here → ------------- ↓ ↓
Map<String,dynamic> toJson(Object Function(T) toJsonT) =>
_$OperationResultGAFToJson<T>(this, toJsonT);
}
If T
were a class named Order
, this Order
class could hold static helper methods for use with genericArgumentFactories:
@JsonSerializable(explicitToJson: true, includeIfNull: false)
class Order implements Serializable {
//<snip>
/// Helper methods for genericArgumentFactories
static Order fromJsonModel(Object? json) => Order.fromJson(json as Map<String,dynamic>);
static Map<String, dynamic> toJsonModel(Order order) => order.toJson();
/// Usual json_serializable stub methods
factory Order.fromJson(Map<String,dynamic> json) => _$OrderFromJson(json);
Map<String,dynamic> toJson() => _$OrderToJson(this);
}
Notice that the above helper methods simply call the usual toJson()
, fromJson()
stub methods generated by json_serializable.
The point of adding such static methods to model classes is to make supplying these helper methods to OperationResultGAF.toJson()
, OperationResultGAF.fromJson()
less verbose: we provide just their function names instead of the actual function.
e.g. Instead of:
OperationResultGAF<Order>.fromJson(_json, (Object? json) => Order.fromJson(json as Map<String,dynamic>));
we can use:
OperationResultGAF<Order>.fromJson(_json, Order.fromJsonModel);
If T
is a List
of objects such as List<MenuItem>
, then we need helper methods that handle lists.
Here's an example of static helper methods to add to MenuItem
class to handle Lists:
static List<MenuItem> fromJsonModelList(Object? jsonList) {
if (jsonList == null) return [];
if (jsonList is List) {
return jsonList.map((json) => MenuItem.fromJson(json)).toList();
}
// We shouldn't be here
if (jsonList is Map<String,dynamic>) {
return [MenuItem.fromJson(jsonList)];
}
// We really shouldn't be here
throw ArgumentError.value(jsonList, 'jsonList', 'fromJsonModelList cannot handle'
' this JSON payload. Please add a handler for this input or use the correct '
'helper method.');
}
/// Not at all comprehensive, but you get the idea
static List<Map<String,dynamic>> toJsonModelList(Object list) {
if (list is List<MenuItem>) {
return list.map((item) => item.toJson()).toList();
}
return [];
}
And an example of how these static helper methods could be called in a unit test:
List<MenuItem> _mListA = [MockData.menuItem1, MockData.menuItem2];
OperationResultGAF<List<MenuItem>> _orC = OperationResultGAF<List<MenuItem>>(
op: Operation.delete, t: _mListA);
/// Use toJsonModelList to produce a List<Map<String,dynamic>>
var _json = _orC.toJson(MenuItem.toJsonModelList);
/// Use fromJsonModelList to convert List<Map<String,dynamic>> to List<MenuItem>
OperationResultGAF<List<MenuItem>> _orD = OperationResultGAF<List<MenuItem>>.fromJson(
_json, MenuItem.fromJsonModelList);
expect(_orC.op, _orD.op);
expect(_orC.t.first.id, _orD.t.first.id);