Kotlin for Android Basics
Data classes as models and sealed classes for UI state — the language-level patterns Kotlin brings to Android.
Why Kotlin changes how Android code is structured
Android development existed for a decade in Java before Kotlin became Google's preferred language in 2019 (covered on the introduction page). This page isn't an Android setup tutorial — no Android Studio, no Gradle, no emulator — it's about the language-level patterns Kotlin brings that show up constantly once you do start writing real Android code: modeling a screen's data with data classes, modeling everything a screen's UI can be in at once with sealed classes, and how those two ideas combine into the dominant pattern for representing UI state today.
Data classes as models
Nearly every Android screen displays something — a user profile, a list of products, a chat message — and that something needs a plain data-holding type. This is exactly what data class (covered on the functions-and-oop page) is for, and in Android code it's the default choice for practically every model:
data class Product(
val id: Int,
val name: String,
val price: Double,
val imageUrl: String?
)
data class CartItem(
val product: Product,
val quantity: Int
) {
val subtotal: Double
get() = product.price * quantity
}
fun main() {
val product = Product(1, "Wireless Mouse", 29.99, null)
val item = CartItem(product, 3)
println(item.subtotal) // 89.97
println(item) // CartItem(product=Product(...), quantity=3) — free toString
println(item.copy(quantity = 5)) // a new CartItem with quantity changed, product untouched
}
The free equals()/hashCode() a data class generates matters more in Android than it might first appear: a RecyclerView adapter (Android's standard scrollable-list UI component) commonly needs to compare old and new list items by content to figure out exactly which rows changed — content-based equality, generated automatically, is precisely what that comparison needs, with no hand-written equals() to get subtly wrong.
Sealed classes for UI state
A sealed class (or sealed interface) restricts a type hierarchy to a fixed, closed set of subclasses, all known at compile time within the same file or module — nothing outside that set can ever extend it. This makes it the natural way to model "a screen can be in exactly one of these states, and no others":
sealed class ProfileUiState {
object Loading : ProfileUiState()
data class Success(val name: String, val email: String) : ProfileUiState()
data class Error(val message: String) : ProfileUiState()
}
object Loading (rather than class Loading) is used because that state carries no data at all — it's a singleton, one shared instance is all that's ever needed, exactly like object elsewhere in Kotlin for a single, stateless instance. Success and Error are data classes because each one genuinely carries different data alongside the state itself.
Consuming a sealed class with when is where the real payoff shows up — the compiler knows the complete, closed set of possible subclasses, so it can verify exhaustiveness: every branch must be handled, with no else needed (and, if a new state is ever added to the sealed class later, every when over it that forgot to handle the new case becomes a compile error immediately, not a runtime surprise months later):
fun renderProfile(state: ProfileUiState): String = when (state) {
is ProfileUiState.Loading -> "Loading..."
is ProfileUiState.Success -> "${state.name} (${state.email})"
is ProfileUiState.Error -> "Error: ${state.message}"
// no 'else' needed — the compiler knows these three are the ONLY possible subclasses
}
fun main() {
println(renderProfile(ProfileUiState.Loading)) // Loading...
println(renderProfile(ProfileUiState.Success("Ada", "ada@example.com"))) // Ada (ada@example.com)
println(renderProfile(ProfileUiState.Error("Network unreachable"))) // Error: Network unreachable
}
Compare this to modeling the same idea with a nullable data class and a boolean flag or two (isLoading: Boolean, error: String?, data: Profile?) — a common pattern in older Java-style Android code. That representation allows nonsensical combinations the type system can't rule out (isLoading = true and data non-null and error non-null, all at once), and every place that reads it has to remember, by convention, which combinations are actually meant to be valid. A sealed class makes the invalid combinations simply unrepresentable — there is no way to construct a ProfileUiState that is loading and simultaneously has success data.
| Boolean flags + nullable fields | Sealed class | |
|---|---|---|
| Invalid states (e.g. loading + error simultaneously) | Possible to construct by accident | Structurally impossible |
| Exhaustiveness when consuming | Not checked — easy to forget a combination | Enforced by the compiler in a when |
| Adding a new state later | Touch every flag/field combination by convention | Compiler flags every when that needs updating |
Putting it together: a simple state-driven example
sealed class OrderUiState {
object Idle : OrderUiState()
object Placing : OrderUiState()
data class Placed(val orderId: String, val items: List<CartItem>) : OrderUiState()
data class Failed(val reason: String) : OrderUiState()
}
data class CartItem(val name: String, val quantity: Int)
fun describeOrderState(state: OrderUiState): String = when (state) {
is OrderUiState.Idle -> "No order in progress"
is OrderUiState.Placing -> "Placing your order..."
is OrderUiState.Placed -> "Order ${state.orderId} placed with ${state.items.size} item(s)"
is OrderUiState.Failed -> "Could not place order: ${state.reason}"
}
fun main() {
var state: OrderUiState = OrderUiState.Idle
println(describeOrderState(state)) // No order in progress
state = OrderUiState.Placing
println(describeOrderState(state)) // Placing your order...
state = OrderUiState.Placed("ORD-1001", listOf(CartItem("Mouse", 2)))
println(describeOrderState(state)) // Order ORD-1001 placed with 1 item(s)
}
In a real Android app (using Jetpack Compose or the older View system alike), this state variable would typically live in a ViewModel, exposed to the UI layer as a stream of values the screen observes and re-renders from — but the core pattern is exactly the plain Kotlin shown here: a sealed class enumerating every possible screen state, and a when that exhaustively renders each one.
Common mistakes
- Modeling UI state with several independent nullable fields and boolean flags instead of a sealed class, which allows nonsensical combinations (loading and showing an error at the same time) that a sealed class rules out structurally.
- Adding an
else ->branch to awhenover a sealed class "just in case," which silently defeats the compiler's exhaustiveness check — if a new state is added later, thatelsebranch quietly swallows it instead of the compiler flagging every place that needs updating. - Using a plain
classfor a model with no meaningful behavior beyond holding data, then manually (and often incorrectly) writingequals()/hashCode()/toString()that adata classwould have generated correctly for free. - Reaching for
class Loadinginstead ofobject Loadingfor a sealed subclass that carries no data — creating unnecessary new instances of something that's really just one shared, stateless marker.
Interview questions
Q: Why is a sealed class typically preferred over a data class with several nullable fields and boolean flags for representing a screen's UI state?
A sealed class makes invalid combinations structurally impossible to construct — a screen literally cannot be in a "loading" state and a "showing data" state at the same time, because those are different subclasses entirely, not different flag combinations on one shared type. It also lets the compiler enforce exhaustiveness on every when that consumes the state, so adding a new state later immediately flags every place that needs to handle it, rather than silently leaving old code that assumed a fixed, smaller set of combinations.
Q: Why does a RecyclerView-style list adapter benefit specifically from using data classes for its item models?
A data class's auto-generated equals()/hashCode() compare by content rather than by reference, which is exactly what an adapter needs when diffing an old list against a new one to figure out which specific rows actually changed, were added, or were removed — comparing by reference (the default for a plain class) would treat every item as "different" even when its actual data is identical, defeating the point of an efficient diff.