Inline Value Classes in Kotlin
Inline Value Classes kapseln primitive Werte typsicher. Du lernst, wie sie IDs schützen und unnötige Objekte vermeiden.
Inline Value Classes helfen dir, primitive Werte wie String, Long oder Int nicht ungeschützt durch deine Android-App zu reichen. Gerade IDs sehen im Code oft gleich aus, bedeuten aber völlig verschiedene Dinge: eine User-ID, eine Chat-ID, eine Datenbank-ID oder ein Permission-Key. Mit einem kleinen Wrapper machst du diese Bedeutung sichtbar und lässt den Compiler viele Verwechslungen früh melden.
Was ist das?
Eine Inline Value Class ist eine Kotlin-Klasse, die genau einen Wert kapselt und mit @JvmInline value class deklariert wird. Du gibst einem primitiven oder bereits vorhandenen Typ damit einen eigenen Namen. Aus String wird zum Beispiel UserId, aus Long wird MessageId, aus Int wird RetryCount. Der enthaltene Wert bleibt technisch klein, aber dein Code bekommt mehr Fachsprache.
Das zentrale Problem ist Typsicherheit. Ohne Wrapper sehen viele Werte für den Compiler identisch aus. Wenn eine Funktion loadUser(id: String) und eine andere Funktion loadProject(id: String) erwartet, kannst du versehentlich die falsche ID übergeben. Der Compiler kann das nicht erkennen, weil beide Werte denselben Typ haben. Du erkennst den Fehler vielleicht erst in einem Test, im Logcat oder in einer Support-Meldung.
Mit Inline Value Classes formulierst du die Absicht im Typ selbst. Eine Funktion kann dann loadUser(userId: UserId) erwarten. Eine ProjectId passt nicht mehr hinein, auch wenn beide intern einen String enthalten. Für Lernende ist das ein wichtiger Schritt: Du verlagerst Wissen aus Kommentaren, Variablennamen und Konventionen in das Typsystem. Das macht Code robuster und leichter reviewbar.
Im Android-Alltag tauchen solche Situationen ständig auf. Du arbeitest mit Navigation-Argumenten, Room-Entities, API-DTOs, Repository-Funktionen, Permission-Flows, Analytics-Events und UI-State. Viele dieser Schichten transportieren kleine Werte mit großer Bedeutung. Wenn du sie nur als primitive Typen behandelst, hängt die Korrektheit stark daran, dass alle Beteiligten diszipliniert bleiben. Inline Value Classes geben dir eine zusätzliche Schutzschicht, ohne dass du für jeden kleinen Wert eine schwere Objektstruktur bauen musst.
Der Begriff inline weist darauf hin, dass Kotlin den Wrapper in vielen Fällen zur Laufzeit nicht als separates Objekt darstellen muss. Der Compiler kann den enthaltenen Wert direkt verwenden. Das ist der Bezug zum Keyword allocation: Du bekommst oft die Lesbarkeit und Typsicherheit eines Wrappers, ohne bei jeder Nutzung ein zusätzliches Objekt anzulegen. Das ist kein Freifahrtschein für Mikro-Optimierung, aber es macht den Typ in typischen Android-Pfaden attraktiv, etwa in ViewModels, Mappern und Compose-State.
Wichtig ist: Eine Inline Value Class ist kein Ersatz für jede Domänenklasse. Sie eignet sich besonders für einzelne, kleine Werte mit eigener Bedeutung. Wenn du mehrere Eigenschaften, Verhalten mit Zustand oder komplexe Validierungsregeln brauchst, ist eine normale data class oder ein anderes Modell passender.
Wie funktioniert es?
Die Syntax ist bewusst klein. Eine Inline Value Class hat genau eine primäre Property:
@JvmInline
value class UserId(val value: String)
@JvmInline
value class ProjectId(val value: String)
Jetzt sind UserId und ProjectId zwei verschiedene Typen. Intern enthalten beide einen String, aber Kotlin behandelt sie im Quellcode getrennt. Eine Funktion, die UserId erwartet, akzeptiert keine ProjectId.
fun loadUser(id: UserId) {
// ...
}
val userId = UserId("u-42")
val projectId = ProjectId("p-42")
loadUser(userId)
// loadUser(projectId) // Kompiliert nicht
Das mentale Modell ist: Du baust eine dünne, benannte Hülle um einen Wert. Diese Hülle ist vor allem für den Compiler und für Leserinnen und Leser da. Sie sagt: Dieser String ist nicht irgendein Text, sondern eine User-ID. Dieser Long ist nicht irgendeine Zahl, sondern eine Datenbank-ID. Dadurch wird die fachliche Bedeutung an jeder Funktionssignatur sichtbar.
Inline Value Classes dürfen Funktionen und Properties enthalten, solange sie keinen zusätzlichen Zustand speichern. Du kannst zum Beispiel eine kleine Validierung anbieten oder eine formatierte Darstellung berechnen. Der eigentliche gespeicherte Wert bleibt aber genau eine Property.
@JvmInline
value class PermissionName(val value: String) {
val isRuntimePermission: Boolean
get() = value.startsWith("android.permission.")
}
Im Android-Kontext ist das nützlich, wenn du Werte aus Plattform-APIs oder Framework-Grenzen in deine eigene Domäne übersetzt. Die Android-Permission-API arbeitet mit String-Konstanten, etwa aus Manifest.permission. In deiner App kannst du trotzdem entscheiden, ob du intern einen benannten Typ verwendest. So verhinderst du, dass ein beliebiger anderer String versehentlich als Permission-Name behandelt wird.
Trotzdem musst du die Grenzen kennen. Sobald ein Wert generisch verwendet, nullable gemacht, in Collections gespeichert oder über Java-Interop gereicht wird, kann die Laufzeitdarstellung anders aussehen. Kotlin kann Inline Value Classes häufig ohne zusätzliche Allocation darstellen, aber nicht in jeder Situation. Für deine Architekturentscheidung heißt das: Verwende sie primär für Klarheit und Typsicherheit. Der mögliche Performance-Vorteil ist ein Bonus, nicht der Hauptgrund.
Ein zweiter Punkt betrifft Serialisierung, Persistenz und Frameworks. Android-Projekte nutzen oft Room, Retrofit, kotlinx.serialization, Moshi, Gson, Navigation und SavedStateHandle. Nicht jedes Tool behandelt Inline Value Classes in jeder Version gleich. Manchmal brauchst du einen TypeConverter, einen Serializer oder eine explizite Umwandlung am Rand deiner Schicht. Das ist kein Argument gegen den Typ, aber es gehört zur sauberen Planung.
Eine sinnvolle Schichtgrenze sieht häufig so aus: Außen kommen primitive Werte aus JSON, Datenbank, Intent, Bundle oder Navigation. Direkt am Rand wandelst du sie in fachliche Typen um. Innerhalb deiner Domain-, Use-Case- und ViewModel-Schicht arbeitest du mit den Inline Value Classes. Beim Speichern oder Senden wandelst du zurück. So bleiben die unvermeidbaren primitiven Werte an den Rändern, statt überall im Code aufzutauchen.
In Compose sind Inline Value Classes ebenfalls praktisch. Ein UI-State kann etwa selectedUserId: UserId? enthalten. Eine Callback-Signatur wie onUserClick: (UserId) -> Unit ist deutlich klarer als (String) -> Unit. Du siehst beim Lesen sofort, was der Callback transportiert. In größeren Screens mit mehreren Listen, Dialogen und Navigation-Aktionen reduziert das echte Fehlerquellen.
In der Praxis
Stell dir eine App vor, die Nutzer und Projekte lädt. Beide IDs kommen vom Backend als String. Ohne Wrapper passiert schnell Folgendes:
data class UserDto(
val id: String,
val name: String
)
data class ProjectDto(
val id: String,
val title: String,
val ownerId: String
)
class Repository {
suspend fun loadUser(id: String): UserDto {
TODO()
}
suspend fun loadProject(id: String): ProjectDto {
TODO()
}
}
Dieser Code ist syntaktisch in Ordnung, aber die Signaturen helfen dir wenig. ownerId, user.id und project.id sind alles Strings. Bei Refactorings, Mappern oder Tests kann eine Verwechslung unauffällig bleiben.
Mit Inline Value Classes wird daraus ein klareres Modell:
@JvmInline
value class UserId(val value: String)
@JvmInline
value class ProjectId(val value: String)
data class User(
val id: UserId,
val name: String
)
data class Project(
val id: ProjectId,
val title: String,
val ownerId: UserId
)
class ProjectRepository(
private val api: ProjectApi
) {
suspend fun loadUser(id: UserId): User {
val dto = api.getUser(id.value)
return User(
id = UserId(dto.id),
name = dto.name
)
}
suspend fun loadProject(id: ProjectId): Project {
val dto = api.getProject(id.value)
return Project(
id = ProjectId(dto.id),
title = dto.title,
ownerId = UserId(dto.ownerId)
)
}
}
@Composable
fun ProjectRow(
project: Project,
onOwnerClick: (UserId) -> Unit
) {
ListItem(
headlineContent = { Text(project.title) },
supportingContent = { Text("Owner: ${project.ownerId.value}") },
modifier = Modifier.clickable {
onOwnerClick(project.ownerId)
}
)
}
Der praktische Gewinn liegt nicht darin, dass der Code länger wird. Der Gewinn liegt darin, dass falsche Kombinationen schwerer werden. loadUser(project.id) kompiliert nicht, weil project.id ein ProjectId ist. In einem Code-Review sieht man außerdem sofort, ob eine Funktion fachlich sauber modelliert ist oder nur beliebige Strings entgegennimmt.
Eine gute Entscheidungsregel lautet: Kapsle primitive Werte, wenn sie über mehrere Schichten wandern und mit anderen Werten desselben primitiven Typs verwechselt werden können. IDs sind der häufigste Fall. Auch E-Mail-Adressen, Permission-Namen, Remote-Config-Keys, Feature-Flag-Namen, Prozentwerte, Pixelwerte oder Zähler können Kandidaten sein. Du solltest aber nicht jeden einzelnen String automatisch einpacken. Wenn ein Wert nur lokal in einer Funktion verwendet wird und keine eigene fachliche Rolle hat, bringt ein Wrapper oft wenig.
Eine typische Stolperfalle ist das zu späte Einführen. Wenn deine App schon viele APIs mit String-IDs hat, wirkt die Umstellung plötzlich groß. Besser ist ein schrittweiser Weg: Beginne an neuen Modulen, an Repository-Grenzen oder bei besonders fehleranfälligen IDs. Ersetze nicht blind alle Typen, sondern dort, wo du konkrete Verwechslungen verhindern willst.
Eine zweite Stolperfalle ist das Vermischen von validierten und unvalidierten Werten. Wenn UserId(" ") überall erzeugt werden kann, ist die Klasse zwar typsicher, aber fachlich schwach. Du kannst Validierung in eine Factory legen:
@JvmInline
value class UserId private constructor(val value: String) {
companion object {
fun from(raw: String): UserId {
require(raw.isNotBlank()) { "UserId darf nicht leer sein." }
return UserId(raw)
}
}
}
Das ist nützlich, wenn ungültige Werte wirklich problematisch sind. Es erhöht aber auch den Aufwand bei Tests, Mapping und Serialisierung. Für viele Android-Modelle reicht zunächst ein öffentlicher Konstruktor mit klarer Nutzungskonvention. Entscheide nach Risiko: Eine sicherheitsrelevante ID oder ein Permission-Name verdient mehr Schutz als ein kurzlebiger UI-Filter.
Bei Room kann es sein, dass du TypeConverter brauchst, wenn deine Entity Inline Value Classes enthält. Bei Retrofit oder JSON-Mapping kann eine explizite DTO-Schicht einfacher sein: DTOs bleiben nah am API-Format, Domain-Modelle nutzen UserId und ProjectId. Diese Trennung ist besonders für Junior-Devs lehrreich, weil sie zeigt, dass externe Datenformate nicht dein internes Modell diktieren müssen.
Auch Tests werden klarer. Du kannst gezielt prüfen, dass ein Mapper die richtige ID an die richtige Stelle setzt:
@Test
fun mapsProjectOwnerToUserId() {
val dto = ProjectDto(
id = "project-1",
title = "Android App",
ownerId = "user-7"
)
val project = dto.toDomain()
assertEquals(ProjectId("project-1"), project.id)
assertEquals(UserId("user-7"), project.ownerId)
}
Der Test liest sich fachlicher als ein Vergleich von zwei Strings. Wenn du versehentlich ProjectId(dto.ownerId) oder UserId(dto.id) verwendest, fällt das schneller auf. Noch besser: Viele falsche Übergaben verhindert bereits der Compiler, bevor der Test läuft.
In Code-Reviews solltest du auf drei Fragen achten. Erstens: Hat der Wrapper einen klaren fachlichen Namen? Id allein ist zu vage, UserId ist besser. Zweitens: Bleiben primitive Werte an den Systemgrenzen, oder sickern sie durch alle Schichten? Drittens: Gibt es unnötige Wrapper, die nur Lärm erzeugen? Gute Typen machen Code lesbarer. Schlechte Typen ersetzen einen einfachen Wert durch zusätzliche Zeremonie ohne Nutzen.
Ein praktischer Android-Bezug sind Permission-Flows. Die Plattform arbeitet mit Strings wie Manifest.permission.CAMERA. Wenn dein Feature mehrere Permission-Gruppen, Erklärdialoge und Analytics-Ereignisse verwaltet, kann ein PermissionName-Typ helfen. Du würdest weiterhin die offiziellen Android-Konstanten verwenden, aber intern signalisieren: Dieser String ist als Permission gedacht, nicht als UI-Label, Route oder Event-Name. Gerade bei APIs, die historisch auf primitiven Typen basieren, können Inline Value Classes deine eigene App-Schicht sauberer machen.
Fazit
Inline Value Classes sind ein kleines Kotlin-Werkzeug mit großem Praxisnutzen für Android-Code, der viele primitive Werte mit eigener Bedeutung transportiert. Du solltest sie vor allem für IDs und ähnliche Domänenwerte einsetzen, wenn Verwechslungen realistisch sind und der Typ über mehrere Schichten läuft. Prüfe dein Verständnis aktiv: Suche in einem bestehenden Projekt nach Funktionen mit mehreren String- oder Long-Parametern, ersetze eine fachliche ID durch eine Inline Value Class, beobachte die Compilerfehler und ergänze einen Mapper-Test. Wenn der Code danach klarer lesbar ist und falsche Übergaben schwerer werden, hast du den richtigen Einsatzpunkt gefunden.