Cosa sono le sealed classes

Con Dart 3 è arrivato un modificatore molto atteso: sealed. Una classe sigillata definisce una gerarchia chiusa, cioè un insieme finito e noto di sottotipi che devono essere dichiarati nello stesso file della classe padre. Questo vincolo, apparentemente restrittivo, sblocca un superpotere del compilatore: il controllo di esaustività nei costrutti switch.

In pratica, il compilatore conosce tutti i possibili sottotipi e ci obbliga a gestirli tutti. Se ne dimentichiamo uno, otteniamo un errore in fase di compilazione anziché un bug a runtime.

sealed vs abstract vs final

È utile chiarire i modificatori di classe introdotti in Dart 3:

  • abstract: non può essere istanziata direttamente, ma può essere estesa/implementata liberamente anche in altri file.
  • sealed: implicitamente abstract, ma i sottotipi devono stare nella stessa libreria (stesso file). Abilita l'esaustività.
  • final: non può essere estesa né implementata fuori dalla libreria.

Una classe sealed non è direttamente istanziabile: serve solo come radice della gerarchia.

Modellare uno stato UI

Il caso d'uso più comune è rappresentare lo stato di una schermata: caricamento, successo, errore. Senza sealed classes finiremmo con nullable e booleani sparsi. Con le sealed classes ogni stato è un tipo a sé:

sealed class ProfileState {
  const ProfileState();
}

class ProfileLoading extends ProfileState {
  const ProfileLoading();
}

class ProfileLoaded extends ProfileState {
  final User user;
  const ProfileLoaded(this.user);
}

class ProfileError extends ProfileState {
  final String message;
  const ProfileError(this.message);
}

Nel widget possiamo usare uno switch espressione, che si integra perfettamente con il pattern matching di Dart 3:

Widget build(BuildContext context) {
  return switch (state) {
    ProfileLoading() => const CircularProgressIndicator(),
    ProfileLoaded(:final user) => Text('Ciao, ${user.name}'),
    ProfileError(:final message) => Text('Errore: $message'),
  };
}

Nota come ProfileLoaded(:final user) estragga direttamente il campo user grazie al destructuring. Non serve alcun cast manuale.

Esaustività: il vantaggio principale

Proviamo ad aggiungere un nuovo stato, per esempio ProfileEmpty:

class ProfileEmpty extends ProfileState {
  const ProfileEmpty();
}

Non appena lo aggiungiamo, ogni switch sullo stato che non gestisce ProfileEmpty genera un errore di compilazione:

The type 'ProfileState' is not exhaustively matched by the switch cases.

Questo trasforma un potenziale bug silenzioso in un errore immediato. Non abbiamo più bisogno del ramo default che maschera i casi dimenticati.

Un tipo Result senza dipendenze

Le sealed classes sono ideali anche per modellare l'esito di un'operazione, alternativa leggera a librerie come dartz:

sealed class Result<T> {
  const Result();
}

class Success<T> extends Result<T> {
  final T value;
  const Success(this.value);
}

class Failure<T> extends Result<T> {
  final Object error;
  const Failure(this.error);
}

Future<Result<User>> fetchUser() async {
  try {
    final user = await api.getUser();
    return Success(user);
  } catch (e) {
    return Failure(e);
  }
}

Al chiamante:

final result = await fetchUser();
final message = switch (result) {
  Success(:final value) => 'Utente: ${value.name}',
  Failure(:final error) => 'Fallito: $error',
};

Combinare con i guard clauses

Il pattern matching supporta le condizioni when, utili per differenziare casi in base al contenuto:

final label = switch (result) {
  Success(:final value) when value.isPremium => 'Utente premium',
  Success(:final value) => 'Utente standard: ${value.name}',
  Failure() => 'Errore di rete',
};

Attenzione: quando si usano i guard, il compilatore non può più garantire l'esaustività solo dai tipi, quindi assicuratevi di avere sempre un caso di fallback per la stessa forma.

sealed o Freezed?

Una domanda frequente è se convenga ancora usare Freezed per le union types. La risposta dipende dal contesto:

  • Usa le sealed classes native quando vuoi zero code generation, build più veloci e una sintassi pulita.
  • Considera Freezed se hai bisogno anche di copyWith, ==/hashCode, toJson/fromJson generati automaticamente su molte classi.

Spesso le sealed classes native coprono l'80% dei casi senza aggiungere build_runner al progetto.

Best practice

  • Dichiara i sottotipi nello stesso file della classe sealed: è obbligatorio ed è anche più leggibile.
  • Preferisci le switch espressioni per sfruttare l'esaustività.
  • Evita il ramo default se vuoi che il compilatore ti avvisi quando aggiungi nuovi sottotipi.
  • Usa il destructuring (:final campo) per un codice più conciso ed evitare cast.

Conclusione

Le sealed classes portano in Dart un modello di dominio più espressivo e sicuro. Rappresentare stati, risultati e gerarchie chiuse diventa naturale, e il compilatore lavora al posto nostro segnalando i casi non gestiti. Se non le hai ancora integrate nei tuoi progetti Flutter, sono uno dei modi più semplici per aumentare la robustezza del codice senza aggiungere dipendenze.