Sealed Classes und Interfaces in Kotlin
Sealed Types modellieren Zustände sicher. Du lernst, wie sie UI-States, Fehler und Ergebnisse klar abbilden.
Sealed Classes und Interfaces helfen dir, in Kotlin begrenzte Alternativen sauber zu modellieren. Genau das brauchst du in Android-Projekten ständig: Ein Screen lädt, zeigt Daten, meldet einen Fehler oder ist leer; ein Repository liefert Erfolg, Netzwerkfehler oder Validierungsfehler; eine Benutzeraktion führt zu einem klaren Ergebnis. Statt solche Fälle mit losen Strings, Boolean-Kombinationen oder nullable Werten zu verstreuen, beschreibst du sie als geschlossene Typ-Hierarchie. Dadurch wird dein Code lesbarer, leichter testbar und robuster gegenüber späteren Änderungen.
Was ist das?
Eine sealed Class oder ein sealed Interface ist ein Kotlin-Typ, dessen direkte Untertypen bewusst begrenzt sind. Du sagst dem Compiler damit: „Diese Alternativen gehören vollständig zu diesem Modell.“ Das ist der entscheidende Unterschied zu einer normalen offenen Vererbung, bei der an vielen Stellen neue Unterklassen entstehen können. Bei sealed Types ist die Menge der direkten Varianten für den Compiler bekannt. Dadurch kann Kotlin bei einem when-Ausdruck prüfen, ob du alle Fälle behandelst.
Das mentale Modell ist einfach: Du beschreibst keine beliebige Familie von Objekten, sondern eine endliche Liste sinnvoller Zustände oder Ergebnisse. Ein Login ist nicht gleichzeitig „loading“, „success“ und „error“. Ein API-Aufruf endet nicht in einer unklaren Mischung aus data != null, error != null und isLoading == false, sondern in genau einem benannten Ergebnis. Sealed Types machen diese fachliche Grenze im Code sichtbar.
Für Android ist das besonders wertvoll, weil Apps zustandsgetrieben sind. Die UI reagiert auf Daten aus ViewModels, Flows, Repositories und Use Cases. In Jetpack Compose wird die Oberfläche aus State abgeleitet. Wenn dein State schwammig modelliert ist, muss die UI viele Sonderfälle erraten. Wenn dein State als sealed Type modelliert ist, wird klar, was der Screen gerade darstellen darf.
Ein typisches Beispiel ist ein Screen-State:
sealed interface ProfileUiState {
data object Loading : ProfileUiState
data class Content(val user: User) : ProfileUiState
data object Empty : ProfileUiState
data class Error(val message: String) : ProfileUiState
}
Hier gibt es keine versteckte fünfte Bedeutung. Entweder wird geladen, Inhalt ist vorhanden, es gibt keinen Inhalt, oder ein Fehler wird angezeigt. Für Lernende ist das ein wichtiger Schritt vom reinen „Code schreiben“ hin zu sauberem Modellieren. Du denkst nicht zuerst an einzelne Variablen, sondern an die möglichen Zustände deines Features.
Wie funktioniert es?
In Kotlin kannst du sealed class oder sealed interface verwenden. Beide begrenzen die direkten Untertypen. Der Unterschied liegt vor allem im Modellierungsstil. Eine sealed Class kann gemeinsamen Zustand oder gemeinsame Implementierung tragen. Ein sealed Interface beschreibt eher eine gemeinsame Rolle, ohne selbst Konstruktorzustand zu besitzen. In Android-Code ist ein sealed Interface oft angenehm für UI-State und Ergebnisse, weil die einzelnen Varianten dann als data object, data class oder normale Klasse unabhängig formuliert werden können.
Der wichtigste Mechanismus ist die erschöpfende Behandlung mit when. Wenn du einen Wert vom sealed Typ auswertest, kennt der Compiler alle direkten Varianten. Verwendest du when als Ausdruck, muss jeder Fall abgedeckt sein, sofern du keinen allgemeinen else-Zweig nutzt. Genau hier entsteht der Qualitätsgewinn.
fun titleFor(state: ProfileUiState): String =
when (state) {
ProfileUiState.Loading -> "Profil wird geladen"
is ProfileUiState.Content -> state.user.name
ProfileUiState.Empty -> "Kein Profil vorhanden"
is ProfileUiState.Error -> "Fehler: ${state.message}"
}
Wenn du später eine neue Variante ergänzt, zum Beispiel Offline, markiert der Compiler die Stellen, an denen dein when nicht mehr vollständig ist. Das ist keine theoretische Hilfe, sondern im Alltag sehr praktisch. Du änderst dein Modell und bekommst direkt Hinweise, welche UI, welcher Test oder welche Mapping-Funktion angepasst werden muss.
Ein else-Zweig nimmt dir diesen Vorteil teilweise weg. Er kann sinnvoll sein, wenn du bewusst externe oder unbekannte Werte abfängst. Bei eigenen sealed Types solltest du ihn aber selten verwenden. Wenn du immer else -> ... schreibst, merkt der Compiler nicht mehr, dass eine neue Variante besondere Behandlung braucht. Gerade bei UI-State führt das später zu Fehlern, weil neue Zustände in einer allgemeinen Standardanzeige verschwinden.
Sealed Types passen gut zur Android-Architektur, weil sie klare Grenzen zwischen Schichten unterstützen. Ein Repository kann ein fachliches Result liefern. Ein Use Case kann dieses Result weiterverarbeiten. Ein ViewModel kann daraus einen UI-State bauen. Die Compose-UI rendert diesen State. Jede Ebene hat damit eine konkrete Aufgabe.
Beispiel für Domain-Ergebnisse:
sealed interface SaveResult {
data object Success : SaveResult
data object NetworkUnavailable : SaveResult
data class ValidationError(val field: String, val reason: String) : SaveResult
data class UnknownError(val cause: Throwable) : SaveResult
}
Das ist besser als ein einzelner Boolean wie true oder false. Ein Boolean sagt nur, ob etwas geklappt hat. Er erklärt nicht, warum es nicht geklappt hat und welche Reaktion sinnvoll ist. Ein sealed Result kann ausdrücken, ob du eine Snackbar zeigst, ein Formularfeld markierst oder einen Retry anbietest.
In Compose ist die Verbindung besonders direkt. Ein ViewModel stellt einen StateFlow<ProfileUiState> bereit. Die Composable sammelt diesen State und entscheidet mit when, was angezeigt wird. Der State ist dabei nicht nur ein technischer Container, sondern die öffentliche Beschreibung des Screens. Wenn diese Beschreibung präzise ist, wird auch die UI einfacher.
In der Praxis
Stell dir vor, du baust einen Profil-Screen. Er lädt einen Benutzer aus einem Repository. Während des Ladens soll ein Fortschrittsindikator erscheinen. Bei Erfolg werden Name und E-Mail angezeigt. Wenn kein Profil existiert, zeigst du einen leeren Zustand. Bei einem Fehler zeigst du eine Fehlermeldung mit Wiederholen-Aktion.
Ein einfacher Aufbau kann so aussehen:
sealed interface ProfileUiState {
data object Loading : ProfileUiState
data class Content(
val name: String,
val email: String
) : ProfileUiState
data object Empty : ProfileUiState
data class Error(
val message: String
) : ProfileUiState
}
class ProfileViewModel(
private val repository: ProfileRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun loadProfile() {
viewModelScope.launch {
_uiState.value = ProfileUiState.Loading
_uiState.value = when (val result = repository.getProfile()) {
ProfileResult.NotFound -> ProfileUiState.Empty
is ProfileResult.Success -> ProfileUiState.Content(
name = result.profile.name,
email = result.profile.email
)
is ProfileResult.Failure -> ProfileUiState.Error(
message = result.message
)
}
}
}
}
@Composable
fun ProfileScreen(
state: ProfileUiState,
onRetry: () -> Unit
) {
when (state) {
ProfileUiState.Loading -> CircularProgressIndicator()
is ProfileUiState.Content -> Column {
Text(text = state.name)
Text(text = state.email)
}
ProfileUiState.Empty -> Text(text = "Kein Profil angelegt")
is ProfileUiState.Error -> Column {
Text(text = state.message)
Button(onClick = onRetry) {
Text(text = "Erneut versuchen")
}
}
}
}
Der Kern ist nicht der einzelne Syntax-Trick, sondern die klare Richtung der Daten. Das Repository liefert ein fachliches Ergebnis. Das ViewModel übersetzt dieses Ergebnis in UI-State. Die Composable rendert nur noch den State. Dadurch bleibt die UI-Schicht schlank und du vermeidest, dass Netzwerkdetails, Datenbankdetails oder Validierungsregeln direkt in der Oberfläche landen.
Eine gute Entscheidungsregel lautet: Verwende einen sealed Type, wenn du eine begrenzte Menge benannter Alternativen hast und jede Alternative eine eigene Bedeutung hat. Verwende ihn nicht, nur weil du drei Werte gruppieren möchtest. Für einfache unveränderliche Daten reicht eine data class. Für eine Liste von festen Konstanten ohne zusätzliche Daten kann ein enum class passen. Ein sealed Type lohnt sich besonders dann, wenn einzelne Varianten unterschiedliche Daten tragen oder verschieden behandelt werden müssen.
Ein häufiger Fehler ist ein zu großer UI-State, der alles enthält: isLoading, data, error, isEmpty, showRetry, message. Solche Modelle erlauben widersprüchliche Kombinationen. Was bedeutet es, wenn isLoading == true und gleichzeitig error != null ist? Vielleicht gibt es eine fachliche Antwort, oft ist es aber nur ein unklarer Zwischenzustand. Ein sealed UI-State verhindert viele dieser ungültigen Kombinationen, weil immer nur eine Variante aktiv ist.
Ein weiterer Stolperpunkt ist die Vermischung von Zuständen und einmaligen Ereignissen. Ein dauerhaft sichtbarer Fehlerzustand kann Teil des UI-State sein. Eine einmalige Navigation oder Snackbar ist oft eher ein Event. Sealed Types können auch Events modellieren, aber du solltest State und Events nicht gedankenlos in dieselbe Hierarchie werfen. Sonst rendert Compose bei Recomposition möglicherweise etwas erneut, das nur einmal passieren sollte. Prüfe daher genau: Beschreibt dieser Typ, wie der Screen gerade aussieht, oder beschreibt er eine einmalige Wirkung?
Auch bei Fehlern solltest du nicht zu früh nur mit Texten arbeiten. Error("Irgendwas ist schiefgelaufen") ist für die UI schnell, aber für Tests, Logging und Entscheidungen schwach. Besser ist oft ein fachlicher Fehlertyp, der später in eine Meldung übersetzt wird. So kannst du im ViewModel oder in der UI gezielt unterscheiden, ob ein Netzwerkproblem, fehlende Berechtigung oder ungültige Eingabe vorliegt.
Für Tests sind sealed Types angenehm. Du kannst jede Variante gezielt erzeugen und prüfen, ob Mapping und UI korrekt reagieren. Ein ViewModel-Test kann zum Beispiel kontrollieren, dass ProfileResult.NotFound zu ProfileUiState.Empty wird. Ein Compose-Test kann prüfen, ob bei ProfileUiState.Error die Retry-Schaltfläche sichtbar ist. Weil die Varianten explizit sind, musst du weniger versteckte Kombinationen erraten.
In Code-Reviews solltest du bei sealed Types auf drei Dinge achten. Erstens: Sind die Varianten fachlich klar benannt? Zweitens: Wird when ohne unnötigen else verwendet, damit neue Varianten auffallen? Drittens: Liegt der Typ in der richtigen Schicht? Ein Domain-Result sollte keine Compose-spezifischen Begriffe enthalten. Ein UI-State darf dagegen Formulierungen enthalten, die für die Anzeige gedacht sind. Diese Trennung hält dein Projekt wartbarer, besonders wenn Features wachsen.
Wenn du von Java oder sehr einfachem Kotlin kommst, kann sealed am Anfang etwas streng wirken. Diese Strenge ist aber genau der Nutzen. Du beschreibst nicht nur, was heute zufällig passiert, sondern welche Fälle dein Feature offiziell kennt. Der Compiler wird damit zu einem Helfer, der dich bei Änderungen an fehlende Behandlungen erinnert.
Fazit
Sealed Classes und Interfaces sind ein kompaktes Werkzeug für präzises Modellieren in Android-Apps. Du nutzt sie, um UI-Zustände, Fehler und Ergebnisse als begrenzte, compilergeprüfte Alternativen auszudrücken. Übe das an einem vorhandenen Screen: Ersetze mehrere lose State-Variablen durch einen sealed UI-State, rendere ihn mit einem vollständigen when, und schreibe für jede Variante mindestens einen kleinen Test oder eine Preview. Prüfe im Debugger, welche Variante zu welchem Zeitpunkt aktiv ist, und achte im Code-Review darauf, ob neue Varianten wirklich überall behandelt werden.