Sealed Class und when in Kotlin
1. Überblick
Wenn ein Wert nur in wenige Fälle fallen kann, z.B. ein Zahlungsergebnis ist erfolgreich, fehlgeschlagen oder ausstehend, möchtest du, dass der Compiler diese Liste kennt. Kotlin bietet sealed class und sealed interface um die Vererbungshierarchie zu sperren. Der when-Ausdruck kann überprüfen, ob du alle Fälle abgedeckt hast.
Nach diesem Artikel wirst du wissen, wie man sealed hierarchies deklariert und exhaustive when-Ausdrücke schreibt. Du wirst auch Guard Conditions nutzen können, Tests für diese Logik schreiben und erkennen, wann man sealed classes nicht verwenden sollte.
2. Das Problem mit offenen Vererbungshierarchien
Versuchen, das Zahlungsergebnis mit einer normalen abstract class zu modellieren:
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 -> "Fehler: ${result.reason}"
else -> "Unbekannt"
}
Der else-Zweig ist erforderlich. Der Grund ist, dass jeder, überall in jedem Modul, eine neue Unterklasse von PaymentResult erstellen kann. Der Compiler kann nicht wissen, ob die Liste der Unterklassen vollständig ist.
Die Folge ist, wenn jemand class Refunded : PaymentResult() hinzufügt, wird der Code immer noch normal kompiliert. Der Refunded-Wert fällt stillschweigend in den "Unbekannt"-Zweig. Diese Art von Fehler taucht normalerweise nur in der Produktion auf.
enum class löst das teilweise, da die Liste der Konstanten fest ist. Jede enum-Konstante ist aber nur eine einzelne Instanz. Du kannst nicht unterschiedliche transactionIds an verschiedene erfolgreiche Zahlungen anhängen.
3. Sealed Class deklarieren
Sealed Class kombiniert die Vorteile beider Ansätze. Die Liste der Unterklassen ist fest wie in einem enum, und jede Unterklasse hat immer noch ihre eigenen Daten wie eine normale Klasse.
3.1. Dependencies
Das Beispiel in diesem Artikel verwendet Kotlin 2.2 auf JVM 21. Die Tests verwenden JUnit 5 und 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>
Du musst auch das kotlin-maven-plugin mit der gleichen Version konfigurieren, um Kotlin-Quellcode zu kompilieren.
3.2. Eine einfache Sealed Hierarchy
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()
}
Jeden Teil des obigen Codes kann man wie folgt verstehen:
sealed class PaymentResultist implizit eine abstrakte Klasse. Du kannstPaymentResult()nicht direkt aufrufen. Sein Constructor ist standardmäßigprotected.SuccessundFailedsinddata classes. Du erhältst automatischequals,hashCode,toStringundcopy, was beim Schreiben von Tests sehr praktisch ist.Pendingträgt keine Daten, daher verwendedata object. Es ist ein Singleton undtoString()gibtPendingaus, anstatt etwas wiePaymentResult$Pending@1b6d3586.
Die wichtigste Regel: alle unmittelbaren Unterklassen müssen im selben Package und Modul wie die sealed class sein. Du kannst sie auf mehrere Dateien im selben Package verteilen. Allerdings kann ein anderes Modul, das deine Bibliothek nutzt, keine neue Unterklasse hinzufügen. Genau wegen dieser Regel kann der Compiler die Liste der Unterklassen genau kennen.
4. Exhaustive when-Ausdruck
4.1. Weglassen des else-Zweigs
Wenn das Subjekt des when-Ausdrucks einen sealed-Typ hat, brauchst du kein else mehr:
fun describe(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "Bezahlt ${result.amount} VND (Transaktions-ID ${result.transactionId})"
is PaymentResult.Failed -> "Fehlgeschlagen: ${result.reason}"
PaymentResult.Pending -> "Wird verarbeitet"
}
fun main() {
println(describe(PaymentResult.Success("TX-1001", 250_000)))
println(describe(PaymentResult.Failed("Karte abgelaufen", retryable = false)))
println(describe(PaymentResult.Pending))
}
Ausgabe beim Ausführen:
Bezahlt 250000 VND (Transaktions-ID TX-1001)
Fehlgeschlagen: Karte abgelaufen
Wird verarbeitet
Es gibt drei bemerkenswerte Punkte im obigen Code:
- Smart Cast: im
is PaymentResult.Success-Zweig wird die Variableresultautomatisch in den TypSuccessumgewandelt. So kannst du aufresult.amountzugreifen, ohne eine manuelle Typkonvertierung. - Mit
objectvergleichst du direkt mitPaymentResult.Pendingohneis. Zu schreibenis PaymentResult.Pendingist auch korrekt, aber nicht notwendig. whenist hier ein Ausdruck, d.h. er gibt einen Wert zurück und wird als Funktionsrumpf verwendet. Einwhen-Ausdruck muss exhaustive sein.
4.2. Der Compiler erkennt Fehler, wenn ein neuer Fall hinzukommt
Angenommen, du fügst einen Rückerstattungsstatus hinzu:
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()
}
Die Funktion describe kompiliert sofort nicht mehr:
e: 'when' expression must be exhaustive. Add the 'is Refunded' branch or an 'else' branch.
Das ist genau der Kernwert von sealed classes. Überall im Code, wo du when auf PaymentResult verwendest, wird der Compiler dich hinweisen. Du kannst keinen Ort vergessen.
4.3. when als Anweisung
Seit Kotlin 1.7 gibt der Compiler einen Fehler aus, auch wenn when als Anweisung verwendet wird (ohne Rückgabewert), wenn du einen Zweig vergisst. Die Bedingung ist, dass das Subjekt einen sealed, enum oder Boolean-Typ hat:
fun log(result: PaymentResult) {
when (result) {
is PaymentResult.Success -> println("INFO Transaktion ${result.transactionId}")
is PaymentResult.Failed -> println("WARN ${result.reason}")
PaymentResult.Pending -> println("DEBUG wird verarbeitet")
is PaymentResult.Refunded -> println("INFO Rückerstattung ${result.transactionId}")
}
}
Wenn du den Refunded-Zweig weglässt, erhältst du einen Compilerfehler, nicht nur eine Warnung wie in älteren Kotlin-Versionen.
5. Sealed Interface
Eine Klasse kann von nur einer Oberklasse erben, kann aber mehrere Interfaces implementieren. Deshalb ist sealed interface flexibler, wenn ein Typ gleichzeitig mehreren Gruppen angehört:
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 -> "Rechnung für ${event.orderId} erstellen"
is OrderShipped -> "Versandnummer ${event.trackingCode}"
is StockReserved -> "Artikel ${event.sku} reservieren"
}
fun emailOf(target: Notifiable): String = when (target) {
is OrderPlaced -> "Bestellbestätigung an ${target.customerEmail} senden"
is OrderShipped -> "Versandbenachrichtigung an ${target.customerEmail} senden"
}
OrderPlaced ist sowohl OrderEvent als auch Notifiable. Die Funktion emailOf benötigt nur zwei Zweige, da der Compiler weiß, dass Notifiable genau zwei Implementierungen hat.
Sealed interface funktioniert auch mit enum. Eine enum class kann ein sealed interface implementieren, und dann wird jede enum-Konstante als Zweig im when gezählt.
5.1. Mehrere Ebenen verschachtelt
Eine Unterklasse kann selbst sealed sein. So kannst du detaillierter gruppieren:
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
}
Der Zweig is PaymentError.Retryable deckt sowohl Timeout als auch GatewayBusy ab, daher ist when exhaustive, obwohl nur zwei Zweige vorhanden sind.
6. Guard Condition in when
Manchmal möchtest du bei dem gleichen Typ unterschiedlich reagieren, je nachdem, welche Daten darin sind. Kotlin 2.2 unterstützt offiziell Guard Conditions (das Schlüsselwort if nach der Bedingung eines Zweigs):
fun nextAction(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "Rechnung senden"
is PaymentResult.Failed if result.retryable -> "Nach 30 Sekunden erneut versuchen"
is PaymentResult.Failed -> "Fehler dem Kunden melden"
PaymentResult.Pending -> "Auf Webhook vom Payment-Gateway warten"
is PaymentResult.Refunded -> "Buchhaltung aktualisieren"
}
Ein Zweig mit Guard passt nur, wenn sowohl der Typ als auch die if-Bedingung erfüllt sind. Der Compiler betrachtet einen Zweig mit Guard nicht als vollständig abgedeckten Fall von Failed. Daher erhältst du noch einen exhaustive-Fehler, wenn dir ein is PaymentResult.Failed-Zweig ohne Guard fehlt.
Hinweis: In Kotlin 2.1 ist Guard Condition noch eine Preview-Funktion und erfordert das Flag
-Xwhen-guards. Überprüfe die Kotlin-Version deines Projekts, bevor du diese Funktion verwendest.
7. Kombiniert in einem Service
In echtem Code ist sealed class üblicherweise der Rückgabetyp eines Service, anstatt eine Exception zu werfen:
data class Order(val id: String, val amount: Long)
class PaymentService {
fun charge(order: Order): PaymentResult = when {
order.amount <= 0 -> PaymentResult.Failed("Ungültiger Betrag", 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("Transaktions-ID: ${result.transactionId}")
else -> println(describe(result))
}
}
Transaktions-ID: TX-A42
In der Funktion charge hat when kein Subjekt, daher wird else benötigt. Hier ist else vollkommen berechtigt, da die Bedingungen beliebige Boolean-Ausdrücke sind.
In main macht die Syntax when (val result = ...) sowohl den Service-Aufruf als auch begrenzt den Scope der Variable result auf den when-Block. Der else-Zweig ist hier absichtlich: Du interessierst dich nur für den erfolgreichen Fall. Kapitel 9 wird die Kosten dieser Wahl klar machen.
8. Test schreiben
Dank data class kannst du Ergebnisse mit isEqualTo sehr prägnant vergleichen:
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class PaymentServiceTest {
private val service = PaymentService()
@Test
fun `gültige Bestellung gibt Success zurück`() {
val result = service.charge(Order("A1", 99_000))
assertThat(result).isEqualTo(PaymentResult.Success("TX-A1", 99_000))
}
@Test
fun `negativer Betrag gibt Failed ohne Wiederholung zurück`() {
val result = service.charge(Order("A2", -5))
assertThat(result).isInstanceOf(PaymentResult.Failed::class.java)
assertThat((result as PaymentResult.Failed).retryable).isFalse()
}
@Test
fun `große Bestellung wechselt zu Pending`() {
assertThat(service.charge(Order("A3", 60_000_000))).isSameAs(PaymentResult.Pending)
}
@Test
fun `wiederholbarer Fehler führt zu Wiederholungslogik`() {
val action = nextAction(PaymentResult.Failed("Gateway Timeout", retryable = true))
assertThat(action).isEqualTo("Nach 30 Sekunden erneut versuchen")
}
}
Diese Test-Sammlung überprüft drei Punkte:
- Der erste Test nutzt die von
data classgenerierteequals-Methode, um alle Daten zu vergleichen. isSameAswird fürdata objectverwendet, daPendingein Singleton ist, daher ist der Referenzvergleich ausreichend.- Der letzte Test überprüft den Zweig mit Guard Condition.
9. Häufige Fehler und wann man sealed class nicht verwenden sollte
Ein else zu einem when auf einem sealed-Typ hinzufügen. Das ist der häufigste Fehler. Ein „sicherer” else-Zweig schaltet den exhaustiveness-Check komplett aus. Wenn du eine neue Unterklasse hinzufügst, wird der Compiler schweigen. Verwende else nur, wenn du wirklich alle übrigen Fälle zusammenfassen möchtest, wie in Kapitel 7.
when ohne Subjekt verwenden. when { result is Success -> ... } wird nicht gegen die sealed hierarchy überprüft. Schreib stattdessen when (result), damit der Compiler die Analyse durchführen kann.
data vor object vergessen. Ein einfaches object Pending funktioniert normalerweise. Aber das log gibt den Klassennamen mit Hash-Code aus, was beim Debuggen verwirrend ist.
Unterklasse außerhalb des Moduls platzieren. Wenn du eine Bibliothek schreibst und Benutzer sollten sie erweitern können (z.B. Plugins), ist sealed class die falsche Wahl. Verwende stattdessen interface oder eine normale abstract class.
Hinweis: Verwende sealed class nicht, wenn:
- Die Fälle nur Konstanten ohne Daten sind, wie
LOW,MEDIUM,HIGH. In diesem Fall istenum classprägnanter und hatentriesundvalueOf.- Die Liste der Unterklassen ändert sich sehr häufig und wird von vielen Teams in verschiedenen Modulen erweitert.
- Das Verhalten sollte im Objekt selbst liegen (Polymorphie durch Method Overriding) anstatt
whenüberall verteilt zu sein. Wenn die gleichewhenan zehn Stellen wiederholt wird, solltest du die Logik in Methoden der einzelnen Unterklassen verschieben.
10. Fazit
Sealed class und sealed interface helfen dir, die Vererbungshierarchie auf ein Modul zu beschränken. Dadurch kennt der Compiler alle möglichen Fälle genau. Kombiniert mit einem when-Ausdruck mit Subjekt erhältst du Smart Cast, brauchst kein else, und erhältst einen Compilerfehler, falls du vergisst, einen neuen Status zu behandeln.
Verwende data class für Fälle mit Daten und data object für Fälle ohne Daten. Wähle sealed interface, wenn ein Typ mehreren Gruppen angehört. Guard Conditions von Kotlin 2.2 helfen bei der Aufteilung von Zweigen basierend auf inneren Daten. Das Wichtigste ist, unnötige else-Zweige zu vermeiden, denn sie eliminieren den Vorteil, den du suchst.