On this page
Picture a screen state with isLoading = true, error = "Timeout" and a non-empty items list, all at once. Which one should the UI show? Bugs like this come from modelling data with the wrong shape. Kotlin gives you three tools to model it precisely.
Quick decision guide
| You need to model⦠| Use | Example |
|---|---|---|
| A bundle of values | data class | Article(id, title, author) |
| A fixed set of constants, each the same shape | enum class | SortOrder.NEWEST / OLDEST / POPULAR |
| A closed set of cases with different data | sealed interface/class | Loading, Success(items), Error(message) |
Data classes
A data class auto-generates equals, hashCode, toString, copy and componentN (destructuring) from its primary-constructor properties.
data class CartItem(val productId: String, val name: String, val qty: Int, val unitPrice: Long)
data class CartUiState(
val items: List<CartItem> = emptyList(),
val couponCode: String = "",
val isCheckingOut: Boolean = false,
) {
val totalPaise: Long get() = items.sumOf { it.qty * it.unitPrice } // derived, not stored
}
// Immutable update: produce a new state instead of mutating
val updated = state.copy(items = state.items.map {
if (it.productId == "p1") it.copy(qty = it.qty + 1) else it
})- Equality matters for UI:
StateFlowskips emitting a value equal to the current one, and Compose can skip recomposition when parameters are equal. - Keep properties
valand collections immutable. A data class withvars can change while it's a key in a map, or while Compose holds it. - Only primary-constructor properties take part in
equals,copyandtoString. Properties in the body don't.
Enum classes
enum class SortOrder(val labelRes: Int, val apiValue: String) {
NEWEST(R.string.sort_newest, "date_desc"),
OLDEST(R.string.sort_oldest, "date_asc"),
POPULAR(R.string.sort_popular, "views_desc");
companion object {
fun fromApi(value: String?): SortOrder = entries.firstOrNull { it.apiValue == value } ?: NEWEST
}
}
// Exhaustive when without else
fun icon(order: SortOrder) = when (order) {
SortOrder.NEWEST -> Icons.Default.ArrowDownward
SortOrder.OLDEST -> Icons.Default.ArrowUpward
SortOrder.POPULAR -> Icons.Default.TrendingUp
}Use entries, available in recent Kotlin versions, to iterate enum values, for example to build a filter-chip row. Be careful persisting enum.name or ordinal: renaming or reordering constants breaks stored data. Store an explicit, stable value instead.
Sealed classes and interfaces
A sealed type's subclasses are all known at compile time, because they must be declared in the same module and package. That lets when be exhaustive: add a new case and every when that doesn't handle it stops compiling.
sealed interface FeedState {
data object Loading : FeedState
data class Success(val posts: List<Post>, val isRefreshing: Boolean = false) : FeedState
data class Error(val message: String, val canRetry: Boolean) : FeedState
}
@Composable
fun FeedScreen(state: FeedState, onRetry: () -> Unit) {
when (state) {
FeedState.Loading -> CircularProgressIndicator()
is FeedState.Success -> PostList(state.posts) // smart cast: state.posts available
is FeedState.Error -> ErrorView(state.message, onRetry.takeIf { state.canRetry })
}
}Sealed interface vs sealed class: prefer sealed interface unless subtypes need shared state or constructor logic. Interfaces allow a class to belong to several sealed hierarchies and keep things lighter.
data object gives singleton cases a readable toString() ("Loading" instead of a hash), which helps logs and tests.
Sealed types for results and events
sealed interface LoginResult {
data class Success(val user: User) : LoginResult
data object InvalidCredentials : LoginResult
data object AccountLocked : LoginResult
data class NetworkError(val cause: Throwable) : LoginResult
}
fun messageFor(result: LoginResult): String = when (result) {
is LoginResult.Success -> "Welcome, ${result.user.name}"
LoginResult.InvalidCredentials -> "Email or password is incorrect"
LoginResult.AccountLocked -> "Account locked. Check your email."
is LoginResult.NetworkError -> "No connection. Try again."
}Compared with throwing exceptions, typed results make every expected outcome visible in the function signature, so callers can't forget the "account locked" case.
Real-world use cases
Screen state
A sealed UiState for screens with mutually exclusive modes. A single data class works for forms where fields coexist.
MVI intents and effects
A sealed Intent and Effect, so every user action and one-off effect is enumerable. See MVVM vs MVI.
Settings options
Enums for theme mode or sort order, with stable values stored in DataStore.
Common mistakes
- Adding an
elsebranch to awhenover a sealed type. That defeats exhaustiveness. List the cases instead. - Using booleans for mutually exclusive states (
isLoading,isError,isEmpty). - Putting
varor mutable lists inside data classes used as UI state. - Persisting enum ordinals.
- Using sealed hierarchies for open-ended data, such as product categories from a server. Those should be data, not types.
Frequently asked questions
Can a data class inherit from another class?
A data class can implement interfaces and extend open or abstract classes (it can't be open itself). It commonly implements a sealed interface, as in the examples above.
Enum or sealed class for a few states?
If every case has the same shape and no per-case data, use an enum. If cases carry different data, use a sealed type.
Are data classes slow because of generated methods?
No. The generated methods are ordinary methods, and R8 removes unused ones in release builds.
Conclusion
Use data classes for values, enums for fixed options, and sealed interfaces for closed sets of differently shaped cases. Together with exhaustive when, they let the compiler enforce that your UI handles every state, which removes a whole category of Android bugs.
Next step: transform lists of these models fluently with Kotlin Collections and Functional Programming.
Official references
π± Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
Walzi4K wallpapers for lock and home screenGet it βΈ
NotiVaultNotification history: recover dismissed notificationsGet it βΈ
Ready to build real Android applications?
Kotlin fluency makes everything else in Android easier, from coroutines and Flow to Compose and clean architecture. The fastest way to build that fluency is to write real Kotlin every day and get feedback on it.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.