Search code examples
flutterdartfreezedjson-serializableflutter-freezed

json_serializable - Add a generic field to a freezed/json_serializable class


How do I make a Freezed object take a generic type? I want to do this:

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:vepo/src/entity_types/option_entity.dart';

part 'vegan_item_tag.freezed.dart';
part 'vegan_item_tag.g.dart';

@freezed
abstract class VeganItemTag<T>
    with _$VeganItemTag<T>
    implements OptionEntity<T> {
  const factory VeganItemTag({int? iconCodePoint, T? id, String? name}) =
      _VeganItemTag;

  const VeganItemTag._();

  factory VeganItemTag.fromJson(Map<String, dynamic> json) =>
      _$VeganItemTagFromJson(json);
}

I've tried using @With.fromString('AdministrativeArea<House>') from the docs but can't apply it correctly to this class.

One of the errors:

lib/src/common/enums/tags/common/vegan_item_tag.freezed.dart:142:32: Error: Too few positional arguments: 2 required, 1 given.
$$_VeganItemTagFromJson(json);

Think I might be on the right track with this, but it no longer generates a vegan_item_tag.g.dart file:

@freezed
abstract class VeganItemTag<T>
    with _$VeganItemTag<T>
    implements OptionEntity<T> {
  const factory VeganItemTag(
      {required int iconCodePoint,
      required T id,
      required String name}) = _VeganItemTag;

  const VeganItemTag._();

  factory VeganItemTag.fromJson(
    Map<String, Object?> json,
    T Function(Object?) fromJsonT,
  ) => VeganItemTag(
      iconCodePoint: json['iconCodePoint'] as int,
      id: fromJsonT(json['id']),
      name: json['name'] as String,
    );
}

Solution

  • There are several solutions to this problem. But in all of them you need to explicitly convert your classes to a generic type Firebase can handle such as String or Map<dynamic, String>.

    The 3 ways to implement such behavior are:

    FromJson ToJson

    This is messier to maintain than JsonConverters on complex scenarios so I would discard this option for your solution.

    JsonConverters

    It works for automatizing conversions of specific classes or abstract classes through inheritance, but from generic types with different data to store it may not be what you require. If you are always saving the same values from the generic type T you may try to use this solution through implemented abstract classes.

    GenericArgumentFactories

    This is what you are actually asking about. Working with genericArgumentFactories on json_serializable and Freezed is not easy and I found a bug on Freezed package meanwhile.

    But I managed to get this code working which is the actual solution 🧭.

    @freezed
    @JsonSerializable(genericArgumentFactories: true)
    class VeganItemTagV2<T> with _$VeganItemTagV2<T> {
      const VeganItemTagV2._();
    
      const factory VeganItemTagV2({
        required int iconCodePoint,
        required T id,
        required String name,
      }) = _VeganItemTag<T>;
    
      //It only works with block bodies and not with expression bodies
      //I don't know why
      factory VeganItemTagV2.fromJson(
          Map<String, dynamic> json, T Function(Object? json) fromJsonT) {
        return _$VeganItemTagV2FromJson<T>(json, fromJsonT);
      }
    
      Map<String, dynamic> toJson(Object Function(T value) toJsonT) {
        return _$VeganItemTagV2ToJson<T>(this, toJsonT);
      }
    }
    

    This adds the converters on the toJson and fromJson methods to be used depending on the generic type.

    NOTE. These methods can't be expressions for some bug as it doesn't compile but it works with block bodies. Freezed does not oficcially support it so you may consider creating this class without Freezed package.

    This is a example with an encapsulated class of a String and a test class to see how it works:

    class VeganId {
      final String id;
    
      VeganId(this.id);
    
      String itemId() {
        return id;
      }
    
      @override
      String toString() {
        return 'VeganId{id: $id}';
      }
    
      @override
      bool operator ==(Object other) =>
          identical(this, other) ||
          other is VeganId && runtimeType == other.runtimeType && id == other.id;
    
      @override
      int get hashCode => id.hashCode;
    }
    

    And the test which works fine

      test('veganItemV2 from and toJson', () {
        final dto = VeganItemTagV2<VeganId>(
          iconCodePoint: 1,
          id: VeganId("veganID"),
          name: "name",
        );
    
        final Map<String, dynamic> actualToJson = dto.toJson((id) => id.itemId());
    
        expect(actualToJson, {"iconCodePoint": 1, "id": "veganID", "name": "name"});
    
        final VeganItemTagV2 actualFromJson = VeganItemTagV2<VeganId>.fromJson(
          actualToJson,
          (json) =>
            VeganId(json as String),
        );
    
        expect(actualFromJson, dto);
      });