Perché automatizzare la serializzazione JSON

Quasi tutte le app Flutter comunicano con API REST che restituiscono dati in formato JSON. Scrivere manualmente i metodi fromJson e toJson per ogni modello è noioso, ripetitivo e soprattutto soggetto a errori: basta un nome di campo sbagliato per introdurre bug difficili da individuare.

La libreria json_serializable, insieme a build_runner, permette di generare automaticamente il codice di serializzazione a partire dalle annotazioni sulle nostre classi. Il risultato è un codice type-safe, verificato in fase di compilazione e sempre coerente con la definizione del modello.

Configurazione delle dipendenze

Aggiungiamo le dipendenze necessarie nel pubspec.yaml:

dependencies:
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.13
  json_serializable: ^6.8.0

json_annotation contiene le annotazioni, mentre json_serializable e build_runner sono strumenti di sviluppo usati solo in fase di generazione del codice.

Creare un modello serializzabile

Definiamo una classe User annotata con @JsonSerializable():

import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart';

@JsonSerializable()
class User {
  final int id;
  final String name;
  final String email;

  User({
    required this.id,
    required this.name,
    required this.email,
  });

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

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

La direttiva part 'user.g.dart'; collega il file generato. Le funzioni _$UserFromJson e _$UserToJson non esistono ancora: verranno create dal generatore.

Generare il codice

Eseguiamo il comando:

dart run build_runner build --delete-conflicting-outputs

Durante lo sviluppo è comodo usare la modalità watch, che rigenera i file automaticamente a ogni modifica:

dart run build_runner watch --delete-conflicting-outputs

Verrà creato il file user.g.dart con l'implementazione dei metodi.

Gestire nomi di campo diversi

Spesso le API usano lo snake_case mentre in Dart preferiamo il camelCase. Possiamo mappare i campi con @JsonKey:

@JsonSerializable()
class User {
  final int id;
  final String name;

  @JsonKey(name: 'created_at')
  final DateTime createdAt;

  @JsonKey(defaultValue: false)
  final bool isActive;

  User({
    required this.id,
    required this.name,
    required this.createdAt,
    required this.isActive,
  });

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

In alternativa, per evitare di annotare ogni singolo campo, possiamo configurare la conversione globale:

@JsonSerializable(fieldRename: FieldRename.snake)
class User {
  // i campi camelCase verranno mappati in snake_case automaticamente
}

Oggetti annidati e liste

json_serializable gestisce automaticamente oggetti annidati, a patto che anche loro siano serializzabili:

@JsonSerializable()
class Post {
  final int id;
  final String title;
  final User author;
  final List<String> tags;

  Post({
    required this.id,
    required this.title,
    required this.author,
    required this.tags,
  });

  factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
  Map<String, dynamic> toJson() => _$PostToJson(this);
}

Il generatore chiamerà User.fromJson per il campo author e convertirà la lista di tag senza codice aggiuntivo.

Conversioni personalizzate con JsonConverter

Quando dobbiamo trasformare un valore in modo non standard (per esempio un timestamp Unix in DateTime), usiamo un JsonConverter:

class TimestampConverter implements JsonConverter<DateTime, int> {
  const TimestampConverter();

  @override
  DateTime fromJson(int json) =>
      DateTime.fromMillisecondsSinceEpoch(json * 1000);

  @override
  int toJson(DateTime object) => object.millisecondsSinceEpoch ~/ 1000;
}

Si applica così al campo:

@TimestampConverter()
final DateTime createdAt;

Integrazione con freezed

Per modelli immutabili con copyWith, uguaglianza e supporto alle union, la combinazione con freezed è la scelta più diffusa:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
class User with _$User {
  const factory User({
    required int id,
    required String name,
    @JsonKey(name: 'created_at') required DateTime createdAt,
  }) = _User;

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

Con freezed non dobbiamo nemmeno scrivere toJson: viene generato insieme a copyWith, toString e ==.

Best practice

  • Non modificare mai i file .g.dart: vengono rigenerati e le modifiche andrebbero perse.
  • Aggiungi i file generati al controllo di versione oppure escludili con .gitignore rigenerandoli in CI, in base alla strategia del team.
  • Usa --delete-conflicting-outputs per evitare errori quando cambi la struttura dei modelli.
  • Centralizza le configurazioni comuni in un file build.yaml per applicarle a tutti i modelli.

Conclusione

L'uso di json_serializable e build_runner elimina il codice ripetitivo, riduce gli errori e mantiene i modelli sempre allineati con i dati ricevuti dalle API. Abbinandolo a freezed ottieni modelli immutabili robusti con pochissimo sforzo, portando la gestione del JSON a un livello professionale.