Generics Basics in Kotlin für Android
Generics machen Kotlin-Code wiederverwendbar. Du behältst Typsicherheit schon beim Kompilieren.
Generics sind ein Grundwerkzeug, sobald dein Kotlin-Code mehr können soll als nur einen einzelnen Spezialfall abzubilden. Du schreibst damit Funktionen und Klassen, die mit unterschiedlichen Typen arbeiten, ohne die Typsicherheit von Kotlin aufzugeben. Für Android ist das besonders wichtig, weil du in echten Apps oft dieselbe Struktur für verschiedene Daten brauchst: Listen von Nutzern, Ergebnisse aus Repositories, UI-State für mehrere Screens oder Callback-Typen für unterschiedliche Aktionen.
Was ist das?
Generics bedeuten: Du verwendest einen Type-Parameter als Platzhalter für einen konkreten Typ. Statt eine Klasse nur für User, Article oder Product zu schreiben, definierst du eine allgemeine Form, die später mit einem konkreten Typ benutzt wird. In Kotlin sieht dieser Platzhalter meist wie T, R oder E aus. Der Name ist frei wählbar, aber kurze Großbuchstaben sind üblich.
Das Problem dahinter ist häufig Wiederholung. Ohne Generics würdest du schnell mehrere sehr ähnliche Klassen bauen: UserResult, ArticleResult, ProductResult. Jede Klasse hätte vielleicht ein data, ein error und ein loading. Die Struktur ist gleich, nur der Typ der Nutzdaten unterscheidet sich. Generics lösen genau diesen Fall. Du beschreibst die gemeinsame Struktur einmal und setzt den konkreten Typ später ein.
Das mentale Modell ist: Eine generische Klasse ist kein ungetypter Behälter. Sie ist ein Behälter mit einem Typ, der erst bei der Verwendung festgelegt wird. List<String> ist eine Liste von Texten. List<Int> ist eine Liste von Zahlen. Beide nutzen dieselbe generische Klasse List<T>, aber Kotlin behandelt sie als unterschiedliche, klar geprüfte Typen. Du kannst deshalb nicht versehentlich ein Int in eine List<String> legen.
Im Android-Kontext ist das kein akademisches Detail. Kotlin und Jetpack-APIs nutzen Generics ständig. StateFlow<UiState>, LiveData<User>, Result<Token>, LazyColumn mit einer Liste von Modellen oder ein Repository mit suspend fun load(): ApiResult<List<Article>> sind typische Beispiele. Wenn du Generics verstehst, liest du solche Signaturen schneller und entwirfst eigene APIs sauberer.
Wie funktioniert es?
Ein Type-Parameter steht in spitzen Klammern hinter einem Klassennamen, Interface-Namen oder Funktionsnamen. Bei class Box<T>(val value: T) ist T der Platzhalter. Wenn du Box("Hallo") erstellst, erkennt Kotlin meistens selbst, dass daraus eine Box<String> wird. Bei Box(42) entsteht eine Box<Int>. Diese automatische Erkennung nennt man Type Inference. Sie spart Schreibarbeit, ersetzt aber nicht das Verständnis der Typen.
Generics wirken zur Compile-Zeit. Der Compiler prüft, ob du einen Wert passend verwendest. Wenn eine Funktion fun render(users: List<User>) erwartet, kannst du keine List<Article> übergeben. Das ist der zentrale Vorteil: Du bekommst eine Fehlermeldung beim Bauen der App, nicht erst als Crash bei einem Kunden.
Generische Funktionen funktionieren ähnlich. Du kannst eine Funktion schreiben, die für viele Typen gilt:
fun <T> firstOrNull(items: List<T>): T? {
return if (items.isEmpty()) null else items[0]
}
Hier ist T nicht an eine konkrete Klasse gebunden. Übergibst du eine List<User>, ist das Ergebnis User?. Übergibst du eine List<String>, ist das Ergebnis String?. Die Funktion bleibt allgemein, aber der Rückgabetyp bleibt präzise. Genau darin liegt der Wert von Generics: wiederverwendbarer Code ohne Verlust von Typinformation.
Du kannst Type-Parameter auch begrenzen. Das ist nützlich, wenn deine generische Logik bestimmte Fähigkeiten des Typs braucht. Beispiel: Wenn du auf eine id zugreifen willst, reicht ein beliebiges T nicht. Dann definierst du ein Interface und beschränkst den Type-Parameter darauf:
interface Identifiable {
val id: String
}
fun <T : Identifiable> findById(items: List<T>, id: String): T? {
return items.firstOrNull { it.id == id }
}
T : Identifiable bedeutet: Der konkrete Typ muss Identifiable erfüllen. Dadurch darf die Funktion sicher auf item.id zugreifen. Ohne diese Grenze würde Kotlin den Zugriff ablehnen, weil ein beliebiger Typ keine id besitzen muss.
In der täglichen Android-Entwicklung siehst du Generics oft bei Datenflüssen und Zuständen. Ein Repository kann einen generischen Ergebnis-Typ liefern. Ein ViewModel kann einen StateFlow<ScreenState> halten. Compose-Funktionen bekommen Listen mit konkreten UI-Modellen. Tests prüfen dann nicht nur Werte, sondern auch, ob deine Typen richtig verbunden sind.
Wichtig ist außerdem: Generics machen deinen Code nicht automatisch besser. Sie sind sinnvoll, wenn mehrere Typen dieselbe Logik teilen. Wenn du Generics einsetzt, obwohl jeder Typ anderes Verhalten braucht, wird der Code schwerer lesbar. Dann ist eine konkrete Klasse, ein Interface oder eine klar benannte Funktion oft besser.
In der Praxis
Ein häufiger Android-Fall ist ein einheitlicher Zustand für Ladeprozesse. Du lädst vielleicht ein Profil, eine Artikelliste oder eine Einstellung. Der Ablauf ist ähnlich: Es gibt einen Ladezustand, erfolgreiche Daten oder einen Fehler. Dafür eignet sich ein generischer Typ.
sealed interface LoadState<out T> {
data object Loading : LoadState<Nothing>
data class Success<T>(val data: T) : LoadState<T>
data class Error(val message: String) : LoadState<Nothing>
}
data class User(
val id: String,
val name: String
)
class UserRepository {
suspend fun loadUser(id: String): LoadState<User> {
return try {
val user = User(id = id, name = "Mina")
LoadState.Success(user)
} catch (exception: Exception) {
LoadState.Error("Nutzer konnte nicht geladen werden.")
}
}
}
Der Type-Parameter T steht hier für die erfolgreichen Daten. Bei LoadState<User> enthält Success einen User. Bei LoadState<List<User>> enthält Success eine Liste von Nutzern. Loading und Error brauchen keine Daten. Deshalb verwenden sie Nothing, einen speziellen Kotlin-Typ, der zu jedem Typ passt, weil er selbst keinen normalen Wert darstellt. Das out T ist eine Varianz-Angabe. Für die Basics reicht die praktische Bedeutung: Dieser Zustand produziert Werte vom Typ T, nimmt aber keine Werte vom Typ T entgegen. Dadurch lässt er sich in vielen UI-Situationen flexibler verwenden.
In Compose könntest du diesen Zustand so auswerten:
@Composable
fun UserContent(state: LoadState<User>) {
when (state) {
LoadState.Loading -> {
CircularProgressIndicator()
}
is LoadState.Success -> {
Text(text = state.data.name)
}
is LoadState.Error -> {
Text(text = state.message)
}
}
}
Der Vorteil ist deutlich: state.data ist hier sicher ein User. Du brauchst keinen Cast und keine Prüfung mit as? User. Der Compiler weiß, welcher Typ in Success liegt. Wenn du später versehentlich UserContent mit LoadState<Article> aufrufst, meldet Kotlin den Fehler früh.
Eine praktische Entscheidungsregel lautet: Verwende Generics, wenn die Struktur und Logik gleich bleiben, aber der Datentyp austauschbar sein soll. Gute Kandidaten sind Wrapper wie LoadState<T>, ApiResult<T>, Page<T>, FormField<T> oder Hilfsfunktionen für Listen. Schlechte Kandidaten sind Fälle, in denen du nur vermeiden willst, über das echte Modell nachzudenken. Ein generischer Typ mit Namen wie DataHolder<T> kann sinnvoll sein, aber wenn niemand mehr erkennt, wofür er steht, verliert dein Code an Klarheit.
Eine typische Stolperfalle ist der Ausweg über Any. Any wirkt zunächst flexibel, aber du gibst damit Typsicherheit auf. Aus List<Any> kann Kotlin nicht mehr ableiten, ob ein Element ein User, ein String oder etwas anderes ist. Du landest schnell bei Casts:
val firstUser = items.first() as User
Solche Casts sind ein Warnsignal. Sie können zur Laufzeit scheitern und machen Tests sowie Code-Reviews schwieriger. Wenn du weißt, dass alle Elemente User sind, nutze List<User>. Wenn du eine allgemeine Liste brauchst, nutze einen Type-Parameter wie List<T>. Any ist eher ein Werkzeug für sehr spezielle Schnittstellen, nicht für normalen App-Code.
Eine zweite Stolperfalle ist ein zu allgemeiner Type-Parameter. Wenn deine Funktion intern eine Eigenschaft oder Methode braucht, musst du das im Typ ausdrücken. Statt fun <T> showId(item: T) zu schreiben und dann mit Umwegen auf eine ID zuzugreifen, definiere eine Grenze wie T : Identifiable. So bleibt der Vertrag sichtbar.
Beim Testen kannst du Generics gut prüfen, indem du denselben generischen Typ mit mehreren konkreten Modellen verwendest. Schreibe zum Beispiel Tests für LoadState<User> und LoadState<List<User>>. Im Debugger kannst du außerdem beobachten, welchen konkreten Wert ein generischer Zustand enthält. In Code-Reviews solltest du besonders auf zwei Fragen achten: Spart der generische Typ echte Wiederholung? Bleibt die Signatur verständlich genug, damit ein anderer Entwickler den erwarteten Typ sofort erkennt?
Fazit
Generics helfen dir, Kotlin-Code wiederverwendbar und trotzdem streng typisiert zu schreiben. Für Android bedeutet das weniger duplizierte Wrapper, klarere Repository-Ergebnisse, sicherere UI-Zustände und weniger riskante Casts. Prüfe dein Verständnis aktiv: Nimm eine Klasse aus deinem Projekt, die es mehrfach mit verschiedenen Datentypen gibt, und überlege, ob ein Type-Parameter die gemeinsame Struktur sauber ausdrückt. Danach schreibe einen kleinen Test oder nutze den Debugger, um zu prüfen, ob der konkrete Typ im Aufrufer erhalten bleibt.