Perché Freezed?

Scrivere modelli di dati in Dart può diventare rapidamente noioso: per ogni classe dobbiamo implementare manualmente copyWith, ==, hashCode, toString e magari la serializzazione JSON. Il rischio di errori è alto e il codice diventa verboso.

Freezed è un package di code generation che risolve questi problemi generando automaticamente tutto il boilerplate per data class immutabili e union types (le cosiddette sealed class). È diventato uno standard di fatto nella community Flutter, soprattutto in combinazione con pattern come BLoC o Riverpod.

Installazione

Aggiungi le dipendenze al pubspec.yaml:

dependencies:
  freezed_annotation: ^2.4.4
  json_annotation: ^4.9.0

dev_dependencies:
  build_runner: ^2.4.13
  freezed: ^2.5.7
  json_serializable: ^6.8.0

json_serializable e json_annotation sono opzionali: servono solo se vuoi anche la serializzazione JSON.

Creare un modello immutabile

Creiamo un modello User. Il file deve dichiarare le due part che ospiteranno il codice generato:

import 'package:freezed_annotation/freezed_annotation.dart';

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

@freezed
class User with _$User {
  const factory User({
    required String id,
    required String name,
    @Default(false) bool isPremium,
    String? email,
  }) = _User;

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

Lancia poi il generatore:

dart run build_runner build --delete-conflicting-outputs

Con poche righe otteniamo gratuitamente:

  • Un costruttore immutabile con parametri nominali.
  • copyWith per creare copie modificate.
  • == e hashCode basati sui valori.
  • toString leggibile.
  • Serializzazione toJson / fromJson.

Usare copyWith e l'immutabilità

final user = User(id: '1', name: 'Anna');

// Non possiamo modificare user, ma possiamo crearne una copia
final premiumUser = user.copyWith(isPremium: true);

print(user.isPremium);        // false
print(premiumUser.isPremium); // true
print(user == premiumUser);   // false (value equality)

Grazie alla value equality generata, due istanze con gli stessi campi risultano uguali, un enorme vantaggio quando confrontiamo stati nell'UI.

Union types: gestire gli stati in modo sicuro

La vera forza di Freezed sono le union (o sealed class), perfette per rappresentare gli stati di una schermata. Immaginiamo lo stato di una richiesta di rete:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'result_state.freezed.dart';

@freezed
class ResultState<T> with _$ResultState<T> {
  const factory ResultState.initial() = Initial<T>;
  const factory ResultState.loading() = Loading<T>;
  const factory ResultState.success(T data) = Success<T>;
  const factory ResultState.error(String message) = Error<T>;
}

Ora possiamo gestire ogni caso in modo esaustivo con when, senza dimenticare alcuno stato (sarà il compilatore ad avvisarci):

Widget build(BuildContext context) {
  return state.when(
    initial: () => const Text('Premi per caricare'),
    loading: () => const CircularProgressIndicator(),
    success: (data) => Text('Dati: $data'),
    error: (message) => Text('Errore: $message'),
  );
}

maybeWhen e map

Quando ci interessa solo un caso, maybeWhen fornisce un fallback con orElse:

final isLoading = state.maybeWhen(
  loading: () => true,
  orElse: () => false,
);

Esistono anche le varianti map, maybeMap e mapOrNull che ricevono l'oggetto tipizzato completo invece dei singoli parametri, utili se hai bisogno dell'intera istanza.

Personalizzazioni utili

  • @Default: assegna un valore di default a un campo.
  • @JsonKey(name: 'user_name'): mappa un campo JSON con nome diverso.
  • Metodi custom: puoi aggiungere getter e metodi tramite un costruttore privato:
@freezed
class Product with _$Product {
  const Product._(); // costruttore privato necessario

  const factory Product({
    required String name,
    required double price,
  }) = _Product;

  String get formattedPrice => '€ ${price.toStringAsFixed(2)}';
}

Suggerimenti pratici

  • Usa build_runner watch durante lo sviluppo per rigenerare automaticamente ad ogni salvataggio.
  • Aggiungi i file *.freezed.dart e *.g.dart al versionamento se il team non rigenera sempre il codice, altrimenti escludili con .gitignore.
  • Con Dart 3 esistono le sealed class native con pattern matching, ma Freezed rimane più conciso e offre copyWith, serializzazione e deep copy pronti all'uso.

Conclusione

Freezed riduce drasticamente il boilerplate, rende i modelli immutabili e sicuri, e con le union types offre un modo elegante e a prova di errore per gestire gli stati dell'applicazione. È uno strumento che vale la pena integrare in ogni progetto Flutter di media o grande dimensione.