Sealed classes : modéliser un ensemble fini de cas sans if/else en cascade

Dans l'article sur Kotlin, un tableau mentionne les sealed classes en une ligne, à côté des data classes et de la null-safety. Ce point mérite d'être détaillé seul : c'est l'outil le plus sous-utilisé du langage pour modéliser un résultat métier, et celui dont l'absence se paie le plus cher en production.
Le problème que résout une sealed class
Modéliser le résultat d'une validation métier avec un enum classique ou une chaîne de statuts fonctionne, jusqu'au jour où deux cas de l'enum transportent des données différentes : un rejet a besoin d'un motif, une validation en attente a besoin d'une date d'échéance, une acceptation n'a besoin de rien. La solution la plus courante, un objet avec des champs optionnels pour chaque cas, laisse des combinaisons incohérentes possibles (un statut « accepté » avec un motif de rejet rempli n'importe où dans le code) que le compilateur ne peut pas détecter.
sealed class LimitCheckResult {
data object Approved : LimitCheckResult()
data class Rejected(val reason: String) : LimitCheckResult()
data class PendingReview(val reviewBy: LocalDate) : LimitCheckResult()
}
Chaque cas ne porte que les données qui le concernent : Approved n'a
aucun champ, Rejected exige un motif, PendingReview exige une date.
Aucune combinaison incohérente n'est représentable.
Ce que l'exhaustivité change vraiment
Le bénéfice concret ne vient pas de la déclaration, mais de l'usage : un
when exhaustif sur une sealed class oblige à traiter chaque cas, et le
compilateur refuse de compiler si un cas est oublié, y compris quand un
nouveau cas est ajouté des mois plus tard.
fun describe(result: LimitCheckResult): String = when (result) {
is LimitCheckResult.Approved -> "Validé"
is LimitCheckResult.Rejected -> "Rejeté : ${result.reason}"
is LimitCheckResult.PendingReview -> "En attente jusqu'au ${result.reviewBy}"
}
Si PendingReview est ajouté six mois après l'écriture de cette fonction,
chaque when exhaustif existant dans la base de code refuse de compiler
tant qu'il n'a pas été mis à jour. Avec un enum classique et un when
non-exhaustif (ou pire, une cascade de if/else if), ce même oubli
compile sans erreur, et le nouveau cas tombe silencieusement dans une
branche else qui n'a pas été pensée pour lui.
| Approche | Nouveau cas oublié dans un traitement | Détection |
|---|---|---|
Enum + when avec else | Compile, tombe dans la branche else | Aucune, découvert en production |
Enum + if/else if en cascade | Compile, tombe dans le dernier else | Aucune, découvert en production |
Sealed class + when exhaustif | Ne compile pas | Immédiate, à la compilation |
La limite : un ajout de cas devient un changement cassant pour les autres
L'exhaustivité du when repose sur le fait que le compilateur connaît
tous les sous-types possibles d'une sealed class au moment de la
compilation ; c'est précisément pour ça qu'une sealed class reste fermée à
son module de déclaration, aucun module externe ne peut lui ajouter un
sous-type. Ce qui mérite d'être anticipé, c'est la conséquence pour les
consommateurs : si cette sealed class fait partie d'une librairie utilisée
par d'autres équipes, ajouter un nouveau cas casse la compilation de tout
when exhaustif existant chez eux, dès qu'ils recompilent contre la
nouvelle version. C'est exactement le comportement recherché à l'intérieur
d'une même équipe (rien ne doit être oublié), mais ça devient un
changement d'API cassant, à documenter et versionner comme tel, dès que la
sealed class franchit la frontière d'une librairie partagée.
Sealed class vs enum : quand utiliser lequel
| Situation | Bon choix |
|---|---|
| Cas fixes sans donnée associée (jours de la semaine, statuts simples) | enum class |
| Cas dont chacun porte des données différentes | sealed class ou sealed interface |
| Besoin de vérifier l'exhaustivité du traitement à la compilation | sealed class avec when exhaustif |
| Sérialisation simple vers une base de données (une seule colonne) | enum class, plus direct |
Le critère qui tranche n'est pas la préférence stylistique, c'est la question « est-ce que chaque cas a besoin de données différentes, et est-ce que je veux que l'ajout d'un cas casse la compilation partout où il n'est pas géré ? ». Si la réponse aux deux est oui, la sealed class n'est pas une option parmi d'autres, c'est le seul outil du langage qui offre cette garantie.