google / json_serializable.dart

Generates utilities to aid in serializing to/from JSON.
https://pub.dev/packages/json_serializable
BSD 3-Clause "New" or "Revised" License
1.54k stars 393 forks source link

[Feature request] Support dart3 sealed class #1342

Open lifeapps-42 opened 11 months ago

lifeapps-42 commented 11 months ago

Thank you for this package!

I failed to find any discussions about this and it feels like essential feture at this moment.

Do you have any plan to implement union-like sealed class serialization, so we can avoid using freezed?

We could use this feature something like this (inspired by freezed):

@JsonSerializable(unionKey: 'subtype') // dafualt 'runtimeType'
sealed class RootSealedClass {
  const RootSealedClass();

  factory RootSealedClass.fromJson(Map<String, dynamic> json) =>
      _$RootSealedClassFromJson(json);

  @JsonValue('first') // or literal class name as default
  const factory RootSealedClass.first(String someAtribute) =
      FirstSealedClassSubtype;

  @JsonValue('second') // or literal class name as default
  const factory RootSealedClass.second(int someOtherAtribute) =
      SecondSealedClassSubtype;

  Map<String, dynamic> toJson();
}

@JsonSerializable()
class FirstSealedClassSubtype extends RootSealedClass {
  const FirstSealedClassSubtype(this.someAtribute);

  factory FirstSealedClassSubtype.fromJson(Map<String, dynamic> json) =>
      _$FirstSealedClassSubtypeFromJson(json);

  final String someAtribute;

  @override
  Map<String, dynamic> toJson() => _$FirstSealedClassSubtypeToJson(this);
}

@JsonSerializable()
class SecondSealedClassSubtype extends RootSealedClass {
  const SecondSealedClassSubtype(this.someOtherAtribute);

  factory SecondSealedClassSubtype.fromJson(Map<String, dynamic> json) =>
      _$SecondSealedClassSubtypeFromJson(json);

  final int someOtherAtribute;

  @override
  Map<String, dynamic> toJson() => _$SecondSealedClassSubtypeToJson(this);
}
kevmoo commented 11 months ago

Maybe? How does Freezed do this?

lifeapps-42 commented 11 months ago

FromJson methods for subtypes are generated as usual:

FirstSealedClassSubtype _$firstSealedClassSubtypeFromJson(Map<String, dynamic> json) =>
    FirstSealedClassSubtype(
      someAtribute: json['someAtribute'] as String,
    );

SecondSealedClassSubtype _$secondSealedClassSubtypeFromJson(Map<String, dynamic> json) =>
    SecondSealedClassSubtype(
      someOtherAtribute: json['someOtherAtribute'] as int,
    );

ToJson methods just need one additional key member called 'subtype' in our case:

Map<String, dynamic> _$firstSealedClassSubtypeToJson(FirstSealedClassSubtype instance) =>
    <String, dynamic>{
      'someAtribute': instance.someAtribute,
      'subtype': 'first', // the value is specified by user inside @JsonValue('first') (see my first comment)
    };

Map<String, dynamic> _$secondSealedClassSubtypeToJson(SecondSealedClassSubtype instance) =>
    <String, dynamic>{
      'someOtherAtribute': instance.someOtherAtribute,
      'subtype': 'second',
    };

Plus we do need root fromJson implementation. That could be just redirecting map/switch:

RootSealedClass _$rootSealedClassFromJson(Map<String, dynamic> json) =>
    switch (json['subtype']) {
      'first' => _$firstSealedClassSubtypeFromJson(json),
      'second' => _$secondSealedClassSubtypeFromJson(json),
      _ => throw ArgumentError() // or some fallback?
    };

The root toJson method should be abstract (so I editted my first comment)

kevmoo commented 11 months ago

I'd take a PR here – It's hard to judge if the complexity is worth it without seeing the PR – which makes me nervous about saying you should make the PR, though. 😄

lifeapps-42 commented 11 months ago

I'd take a PR here – It's hard to judge if the complexity is worth it without seeing the PR – which makes me nervous about saying you should make the PR, though. 😄

I have precisely 0 exp with code generation)) But I'll try

mateusfccp commented 11 months ago

This would be a nice addition.

Currently, we can implement it by manually marking the sealed class subtypes with @JsonSerializable, but if we want to have a fromJson in the sealed class we have to implement it manually, which is a little burdensome and error-prone...

erksch commented 1 month ago

The workaround described by @mateusfccp has the limitation that another @JsonSerializable class that has the sealed class as one of its properties can not be serialized.

sealed class Root {
  factory Root.fromJson(Map<String, dynamic> json) {
    final discriminator = json['type'] as String;

    switch (discriminator) {
      case 'child1':
        return Child1.fromJson(json);
      case 'child2':
        return Child2.fromJson(json);
      default:
        throw Exception('Unknown class: $discriminator');
    }
  }

  static Map<String, dynamic> toJson(Root obj) {
    switch (obj.runtimeType) {
      case Child1:
     final data = (obj as Child1).toJson();
         data['type'] = "child1";
         return data;
     case Child2:
     final data = (obj as Child1).toJson();
         data['type'] = "child2";
         return data;
      default:
        throw Exception('Unknown class: $obj');
    }
  }
}

@JsonSerializable(explicitToJson: true)
class Child1 extends Root {
  const Child1();

  factory Child1.fromJson(Map<String, dynamic> json) => _$Child1FromJson(json);

  Map<String, dynamic> toJson() => _$Child1ToJson(this);
}

@JsonSerializable(explicitToJson: true)
class Child2 extends Root {
  const Child2);

  factory Child2.fromJson(Map<String, dynamic> json) => _$Child2FromJson(json);

  Map<String, dynamic> toJson() => _$Child2ToJson(this);
}

@JsonSerializable(explicitToJson: true)
class MyDataClass {
  final data: Root; // <--- can not be serialized

  MyDataClass(this.data);

  factory MyDataClass.fromJson(Map<String, dynamic> json) => _$MyDataClassFromJson(json);

  Map<String, dynamic> toJson() => _$MyDataClassToJson(this);
}