🥶

Freezed Union の fromJson に関するメモ

2024/09/09に公開

freezed の Union Key で指定されたカラムに対して、 json_serializable の FieldRename を併用しているときに、 fromJson で少しハマったのでメモ。

前提

以下のようなデータがAPIからJSONとして返ってくるとする。

[
  {
    "animal_type": "dog",
    ...
  },
  {
    "animal_type": "cat",
    ...
  }
]

TypeScript で表現すると、以下の Response 型が返ってくるイメージ。

response.ts
type Response = Animal[];

type Animal = Dog | Cat;

type Dog = {
  animal_type: 'dog';
  ...
};

type Cat = {
  animal_type: 'cat';
  ...
};

このとき、これらのデータを Freezed の Union を用いた以下の Animal クラスに変換したい。
JSON 側のキーは、snake_case なので、 @JsonSerializable(fieldRename: FieldRename.snake) を用いる。

Freezed の Union key については以下を参照。

https://pub.dev/packages/freezed#union-types

animal.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'animal.freezed.dart';
part 'animal.g.dart';

enum AnimalType {
  dog,
  cat,
  ;
}

(unionKey: 'animalType')
sealed class Animal with _$Animal {
  const Animal._();

  ('dog')
  (fieldRename: FieldRename.snake)
  factory Animal.dog({
     (AnimalType.dog) AnimalType animalType,
  }) = Dog;

  ('cat')
  (fieldRename: FieldRename.snake)
  factory Animal.cat({
     (AnimalType.cat) AnimalType animalType,
  }) = Cat;

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

問題

上の状態で、 build_runner を走らせてビルドすると、ビルドは成功する。

一方で、実際に Animal クラスを用いて、以下のように JSON から変換しようとすると、ランタイムでエラーが発生する。

sample.dart
import 'dart:convert';

import 'package:sample/animal.dart';

void main() {
  final json = jsonDecode('[{"animal_type":"dog"},{"animal_type":"cat"}]')
      as List<dynamic>;
  final response =
      json.cast<Map<String, dynamic>>().map(Animal.fromJson).toList();
  print(response);
}
Unhandled exception:
CheckedFromJsonException
Could not create `Animal`.
There is a problem with "animalType".
Invalid union type "null"!

つまり JSON 側に animalType がないというエラーが出ている。

原因

実際に生成されたファイルを見ると原因はわかる。

Animal.fromJson は実際には、 animal.freezed.dart の以下の関数が呼ばれているので見てみると、 json['animalType'] で分岐していることがわかる。

animal.freezed.dart
...
Animal _$AnimalFromJson(Map<String, dynamic> json) {
  switch (json['animalType']) {
    case 'dog':
      return Dog.fromJson(json);
    case 'cat':
      return Cat.fromJson(json);

    default:
      throw CheckedFromJsonException(json, 'animalType', 'Animal',
          'Invalid union type "${json['animalType']}"!');
  }
}
...

FieldRename.snake をアノテーションに指定したため、以下のように生成されてくれればよかったが、@JsonSerializable に指定しただけのため、この設定が freezed 側の生成ファイル (.freezed.dart) に影響を与えることはできない。

animal.freezed.dart
...
Animal _$AnimalFromJson(Map<String, dynamic> json) {
-   switch (json['animalType']) {
+   switch (json['animal_type']) {
...

解決策

自動生成の fromJson が期待したものではないだけなので、 解決策としては fromJson を自前実装すれば良い。

最終的には以下のコードで動くようになる。

animal.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'animal.freezed.dart';
part 'animal.g.dart';

enum AnimalType {
  dog,
  cat,
  ;
}

- (unionKey: 'animalType')
+ (unionKey: 'animalType', fromJson: true)
sealed class Animal with _$Animal {
  const Animal._();

  ('dog')
  (fieldRename: FieldRename.snake)
  factory Animal.dog({
     (AnimalType.dog) AnimalType animalType,
  }) = Dog;

  ('cat')
  (fieldRename: FieldRename.snake)
  factory Animal.cat({
     (AnimalType.cat) AnimalType animalType,
  }) = Cat;

-   factory Animal.fromJson(Map<String, dynamic> json) => _$AnimalFromJson(json);
+   factory Animal.fromJson(Map<String, dynamic> json) {
+     switch (json['animal_type']) {
+       case 'dog':
+         return Dog.fromJson(json);
+       case 'cat':
+         return Cat.fromJson(json);
+ 
+       default:
+         throw CheckedFromJsonException(
+           json,
+           'animal_type',
+           'Animal',
+           'Invalid union type "${json['animal_type']}"!',
+         );
+     }
+   }
}

1点ポイントとして、 @Freezed アノテーションに fromJson: true を追加している。

factory Animal.fromJson(Map<String, dynamic> json) を自前実装したことにより、 _$AnimalFromJson(json) を削除することになる。
これにより、 json_serializable 側の fromJson メソッド (Dog.fromJson, Cat.fromJson) が自動的には生成されないようになるため、 fromJson: true を指定することで強制的に fromJson メソッドを生成するようにしている。

fromJson の自前実装部分は、元々 freezed 側で生成されていたものをほとんどそのままコピーしただけ。

懸念点

自前実装だと少し保守性が下がるところが懸念点。
例えば以下のような変更があった際には、 fromJson メソッドを手動で修正する必要がある。

  • unionKey が変更された場合
  • AnimalType に要素が追加された場合
  • (あまりなさそう)FieldRename の設定変更された場合

最後に

他に良い方法をご存じの方はぜひ教えて下さい!

mutex Official Tech Blog

Discussion