How to use generics and list of generics with json serialization in Dart?

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+) :

  1. Helper Class: JsonConverter
  2. Helper Methods: @JsonKey(fromJson:, toJson:)
  3. 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):

  • _fromJson
  • _toJson

(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);

Leave a Comment