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
.gitignorerigenerandoli in CI, in base alla strategia del team. - Usa
--delete-conflicting-outputsper evitare errori quando cambi la struttura dei modelli. - Centralizza le configurazioni comuni in un file
build.yamlper 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.
