Sealed class et when dans Kotlin
1. Aperçu
Quand une valeur peut seulement correspondre à quelques cas définis, par exemple un résultat de paiement qui est réussi, échoué ou en attente, vous voulez que le compilateur connaisse cette liste précise. Kotlin fournit sealed class et sealed interface pour verrouiller la hiérarchie d’héritage. L’expression when peut vérifier que vous avez traité toutes les branches.
Après avoir lu cet article, vous saurez comment déclarer une hiérarchie sealed et écrire un when exhaustif. Vous saurez aussi utiliser les guard conditions, écrire des tests pour cette logique et reconnaître quand ne pas utiliser sealed class.
2. Le problème avec l’héritage ouvert
Essayons de modéliser un résultat de paiement avec une simple abstract class :
abstract class PaymentResult
class Success(val transactionId: String) : PaymentResult()
class Failed(val reason: String) : PaymentResult()
fun describe(result: PaymentResult): String = when (result) {
is Success -> "OK: ${result.transactionId}"
is Failed -> "Erreur : ${result.reason}"
else -> "Indéterminé"
}
La branche else est obligatoire. La raison est que n’importe qui, dans n’importe quel module, peut créer une sous-classe supplémentaire de PaymentResult. Le compilateur ne peut pas savoir si la liste des sous-classes est complète.
En conséquence, quand quelqu’un ajoute class Refunded : PaymentResult(), le code compile toujours correctement. La valeur Refunded tombe silencieusement dans la branche "Indéterminé". Ce type d’erreur n’est généralement découvert qu’en production.
enum class résout partiellement le problème car la liste des constantes est fixe. Cependant, chaque constante enum est une instance unique. Vous ne pouvez pas attacher des transactionId différents pour chaque paiement réussi.
3. Déclarer une sealed class
Sealed class combine les avantages des deux approches précédentes. La liste des sous-classes est fixe comme dans enum, et chaque sous-classe porte ses propres données comme une classe normale.
3.1. Dépendances
L’exemple utilise Kotlin 2.2 sur JVM 21. Les tests utilisent JUnit 5 et AssertJ :
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>2.2.20</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Vous devez également configurer kotlin-maven-plugin à la même version pour compiler le code source Kotlin.
3.2. Une hiérarchie sealed simple
sealed class PaymentResult {
data class Success(val transactionId: String, val amount: Long) : PaymentResult()
data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
data object Pending : PaymentResult()
}
Chaque partie du code ci-dessus a le sens suivant :
sealed class PaymentResultest une classe abstraite implicite. Vous ne pouvez pas appelerPaymentResult()directement. Son constructeur estprotectedpar défaut.SuccessetFailedsont desdata class. Vous avezequals,hashCode,toStringetcopyd’office, très pratiques pour écrire des tests.Pendingne porte pas de données, donc nous utilisonsdata object. C’est un singleton et sontoString()affichePendingau lieu d’un type commePaymentResult$Pending@1b6d3586.
La règle la plus importante : toutes les sous-classes directes doivent être dans le même package et le même module que la sealed class. Vous pouvez les répartir dans plusieurs fichiers du même package. Cependant, un autre module utilisant votre bibliothèque ne pourra pas ajouter de nouvelles sous-classes. C’est grâce à cette règle que le compilateur connaît avec certitude la liste complète des sous-classes.
4. L’expression when exhaustive
4.1. Omettre la branche else
Quand le sujet du when est d’un type sealed, vous n’avez plus besoin de else :
fun describe(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "Paiement de ${result.amount} VND effectué (code ${result.transactionId})"
is PaymentResult.Failed -> "Échec : ${result.reason}"
PaymentResult.Pending -> "En attente de traitement"
}
fun main() {
println(describe(PaymentResult.Success("TX-1001", 250_000)))
println(describe(PaymentResult.Failed("Carte expirée", retryable = false)))
println(describe(PaymentResult.Pending))
}
Résultat à l’exécution :
Paiement de 250000 VND effectué (code TX-1001)
Échec : Carte expirée
En attente de traitement
Il y a trois points à noter dans le code ci-dessus :
- Smart cast : dans la branche
is PaymentResult.Success, la variableresultest automatiquement convertie vers le typeSuccess. Vous pouvez donc accéder àresult.amountsans cast manuel. - Avec
object, vous comparez directement avecPaymentResult.Pendingsansis. Écrireis PaymentResult.Pendingest également correct, mais pas nécessaire. - Le
whenici est une expression, c’est-à-dire qu’il retourne une valeur et est utilisé comme le corps de la fonction. Une expressionwhendoit être exhaustive.
4.2. Le compilateur détecte une erreur quand vous ajoutez un nouveau cas
Supposons que vous ajoutiez un nouvel état de remboursement :
sealed class PaymentResult {
data class Success(val transactionId: String, val amount: Long) : PaymentResult()
data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
data object Pending : PaymentResult()
data class Refunded(val transactionId: String) : PaymentResult()
}
La fonction describe ne compile plus :
e: 'when' expression must be exhaustive. Add the 'is Refunded' branch or an 'else' branch.
C’est exactement la valeur fondamentale de sealed class. Tous les endroits du code utilisant when sur PaymentResult sont signalés par le compilateur. Vous ne pouvez rien oublier.
4.3. L’instruction when
À partir de Kotlin 1.7, même si when est utilisé comme une instruction (sans valeur de retour), le compilateur signale une erreur si vous manquez une branche. La condition est que le sujet soit d’un type sealed, enum ou Boolean :
fun log(result: PaymentResult) {
when (result) {
is PaymentResult.Success -> println("INFO paiement ${result.transactionId}")
is PaymentResult.Failed -> println("WARN ${result.reason}")
PaymentResult.Pending -> println("DEBUG en attente")
is PaymentResult.Refunded -> println("INFO remboursement ${result.transactionId}")
}
}
Si vous omettez la branche Refunded, vous obtenez une erreur de compilation, pas seulement un avertissement comme dans les anciennes versions de Kotlin.
5. Sealed interface
Une classe ne peut hériter que d’une classe parent, mais peut implémenter plusieurs interfaces. Par conséquent, sealed interface est plus flexible quand un type appartient à plusieurs groupes à la fois :
sealed interface OrderEvent
sealed interface Notifiable {
val customerEmail: String
}
data class OrderPlaced(val orderId: String, override val customerEmail: String) : OrderEvent, Notifiable
data class OrderShipped(val orderId: String, val trackingCode: String, override val customerEmail: String) : OrderEvent, Notifiable
data class StockReserved(val orderId: String, val sku: String) : OrderEvent
fun handle(event: OrderEvent): String = when (event) {
is OrderPlaced -> "Créer une facture pour ${event.orderId}"
is OrderShipped -> "Code de suivi ${event.trackingCode}"
is StockReserved -> "Réserver le stock ${event.sku}"
}
fun emailOf(target: Notifiable): String = when (target) {
is OrderPlaced -> "Envoyer la confirmation de commande à ${target.customerEmail}"
is OrderShipped -> "Envoyer la notification de livraison à ${target.customerEmail}"
}
OrderPlaced est à la fois OrderEvent et Notifiable. La fonction emailOf n’a besoin que de deux branches, car le compilateur sait que Notifiable n’a exactement que deux implémentations.
Sealed interface fonctionne aussi avec enum. Un enum class peut implémenter une sealed interface, et chaque constante enum est alors traitée comme une branche dans when.
5.1. Imbrication sur plusieurs niveaux
Une sous-classe peut aussi être sealed. Cela vous permet de regrouper plus finement :
sealed interface PaymentError {
sealed interface Retryable : PaymentError
data object Timeout : Retryable
data object GatewayBusy : Retryable
data class CardDeclined(val code: String) : PaymentError
}
fun shouldRetry(error: PaymentError): Boolean = when (error) {
is PaymentError.Retryable -> true
is PaymentError.CardDeclined -> false
}
La branche is PaymentError.Retryable couvre à la fois Timeout et GatewayBusy, donc le when est toujours exhaustif avec seulement deux branches.
6. Conditions de garde dans when
Parfois, pour le même type, vous voulez un traitement différent selon les données à l’intérieur. Kotlin 2.2 supporte officiellement les guard conditions (mot-clé if après la condition de branche) :
fun nextAction(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "Envoyer la facture"
is PaymentResult.Failed if result.retryable -> "Réessayer après 30 secondes"
is PaymentResult.Failed -> "Signaler l'erreur au client"
PaymentResult.Pending -> "Attendre le webhook de la passerelle de paiement"
is PaymentResult.Refunded -> "Mettre à jour le grand livre"
}
Une branche avec guard ne correspond que si le type ET la condition if sont vrais. Le compilateur ne considère pas qu’une branche avec guard couvre Failed. Donc si vous omettez la branche is PaymentResult.Failed sans guard, vous obtenez toujours une erreur d’exhaustivité.
Note : Dans Kotlin 2.1, les guard conditions sont encore une fonctionnalité en aperçu qui nécessite le drapeau
-Xwhen-guards. Vérifiez la version de Kotlin de votre projet avant d’utiliser.
7. Combiner dans un service
Dans un code réel, sealed class est souvent le type de retour d’un service au lieu de lever une exception :
data class Order(val id: String, val amount: Long)
class PaymentService {
fun charge(order: Order): PaymentResult = when {
order.amount <= 0 -> PaymentResult.Failed("Montant invalide", retryable = false)
order.amount > 50_000_000 -> PaymentResult.Pending
else -> PaymentResult.Success("TX-${order.id}", order.amount)
}
}
fun main() {
val service = PaymentService()
when (val result = service.charge(Order("A42", 120_000))) {
is PaymentResult.Success -> println("Code de transaction : ${result.transactionId}")
else -> println(describe(result))
}
}
Code de transaction : TX-A42
Dans la fonction charge, le when n’a pas de sujet donc a besoin de else. Ici, else est entièrement justifié car les conditions sont des expressions booléennes arbitraires.
Dans main, la syntaxe when (val result = ...) appelle le service tout en limitant la portée de la variable result au bloc when. La branche else ici est volontaire : vous ne vous souciez que du cas réussi. La section 9 expliquera le coût de ce choix.
8. Écrire des tests
Grâce à data class, vous pouvez comparer les résultats avec isEqualTo de manière très concise :
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class PaymentServiceTest {
private val service = PaymentService()
@Test
fun `une commande valide retourne Success`() {
val result = service.charge(Order("A1", 99_000))
assertThat(result).isEqualTo(PaymentResult.Success("TX-A1", 99_000))
}
@Test
fun `montant négatif retourne Failed non-retryable`() {
val result = service.charge(Order("A2", -5))
assertThat(result).isInstanceOf(PaymentResult.Failed::class.java)
assertThat((result as PaymentResult.Failed).retryable).isFalse()
}
@Test
fun `grande commande passe à Pending`() {
assertThat(service.charge(Order("A3", 60_000_000))).isSameAs(PaymentResult.Pending)
}
@Test
fun `erreur retryable conduit à action retry`() {
val action = nextAction(PaymentResult.Failed("Gateway timeout", retryable = true))
assertThat(action).isEqualTo("Réessayer après 30 secondes")
}
}
Cette suite de tests vérifie trois points :
- Le premier test s’appuie sur
equalsgénéré pardata classpour comparer toutes les données. isSameAsest utilisé pourdata object, carPendingest un singleton, donc la comparaison de référence suffit.- Le dernier test vérifie la branche avec guard condition.
9. Erreurs courantes et quand ne pas utiliser
Ajouter else à when sur un type sealed. C’est l’erreur la plus courante. Une branche else “au cas où” désactive complètement le mécanisme de vérification d’exhaustivité. Quand vous ajoutez une nouvelle sous-classe, le compilateur reste silencieux. N’utilisez else que si vous voulez vraiment regrouper tous les cas restants, comme dans l’exemple de la section 7.
Utiliser when sans sujet. when { result is Success -> ... } n’est pas vérifié comme exhaustif selon la hiérarchie sealed. Écrivez when (result) pour que le compilateur puisse l’analyser.
Oublier data pour object. Un simple object Pending fonctionnera généralement. Cependant, les logs afficheront le nom de la classe avec un code de hash, ce qui est difficile à lire lors du débogage.
Placer les sous-classes en dehors du module. Si vous écrivez une bibliothèque et voulez que les utilisateurs la prolongent (par exemple avec des plugins), sealed class est le mauvais choix. Utilisez interface ou abstract class normalement.
Note : Ne devrait pas utiliser sealed class quand :
- Les cas sont juste des constantes sans données, comme
LOW,MEDIUM,HIGH. Dans ce cas,enum classest plus concis et aentries,valueOfd’office.- La liste des sous-classes change très souvent et plusieurs équipes dans plusieurs modules y contribuent.
- Le comportement devrait être dans l’objet lui-même (polymorphisme via surcharge de méthode) au lieu d’éparpiller
whenpartout. Si le mêmewhense répète dans dix endroits, envisagez de déplacer la logique vers une méthode de chaque sous-classe.
10. Conclusion
Sealed class et sealed interface vous aident à verrouiller la hiérarchie d’héritage dans un module. Grâce à cela, le compilateur connaît tous les cas possibles. Combiné avec when ayant un sujet, vous obtenez le smart cast, pas besoin de else, et une erreur de compilation si vous oubliez un nouvel état.
Utilisez data class pour les cas avec données et data object pour les cas sans données. Choisissez sealed interface quand vous avez besoin d’un type appartenant à plusieurs groupes. Les guard conditions de Kotlin 2.2 aident à séparer les branches selon les données à l’intérieur. Le plus important est d’éviter les branches else inutiles, car elles éliminent l’avantage que vous recherchez.