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: implicitamenteabstract, 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/fromJsongenerati 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
defaultse 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.