🟣 Kotlin Programming

Data Classes, Sealed Classes, and Enum Classes

Three special kinds of classes do much of the modelling work in Android apps. Data classes hold values, enums list fixed options, and sealed types describe a closed set of possibilities. Choosing the right one makes impossible states impossible.

✍️ Yunok Tech πŸ“… Updated ⏱ 3 min read 🎯 Intermediate
On this page
  1. Quick decision guide
  2. Data classes
  3. Enum classes
  4. Sealed classes and interfaces
  5. Sealed types for results and events
  6. Real-world use cases
  7. Common mistakes
  8. Frequently asked questions
  9. Conclusion

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…UseExample
A bundle of valuesdata classArticle(id, title, author)
A fixed set of constants, each the same shapeenum classSortOrder.NEWEST / OLDEST / POPULAR
A closed set of cases with different datasealed interface/classLoading, Success(items), Error(message)
Sealed interface with subtypes for screen state A sealed interface FeedState has three subtypes: Loading as a data object with no data, Success as a data class with a list of posts, and Error as a data class with a message and a retry flag. A when expression over FeedState must handle all three. sealed interface FeedState data object Loadingno data data class Successposts: List<Post> data class Errormessage, canRetry when (state) { … } must cover all three, so the compiler enforces it
A sealed type makes "loading and error at the same time" unrepresentable.

Data classes

A data class auto-generates equals, hashCode, toString, copy and componentN (destructuring) from its primary-constructor properties.

Data classes in UI stateKotlin
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: StateFlow skips emitting a value equal to the current one, and Compose can skip recomposition when parameters are equal.
  • Keep properties val and collections immutable. A data class with vars can change while it's a key in a map, or while Compose holds it.
  • Only primary-constructor properties take part in equals, copy and toString. Properties in the body don't.

Enum classes

Enums with propertiesKotlin
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.

FeedState.ktKotlin
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

Typed results from a repositoryKotlin
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 else branch to a when over a sealed type. That defeats exhaustiveness. List the cases instead.
  • Using booleans for mutually exclusive states (isLoading, isError, isEmpty).
  • Putting var or 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.

See all our apps β†’

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.

Share: LinkedIn X / Twitter WhatsApp