Kotlin Sealed Classes & When Expressions — Modeling State
Learn how Kotlin sealed classes and when expressions work together to model state, handle API responses, and eliminate impossible states in your code.
Every app has state. A network call is loading, succeeded, or failed. A user is logged in or logged out. A payment is pending, completed, or refunded. The question is: how do you represent these states in code?
In Java, you’d use enums, constants, or inheritance hierarchies. Each has problems — enums can’t hold different data per variant, constants don’t give you type safety, and inheritance hierarchies are open-ended.
Kotlin’s answer: sealed classes. Combined with when expressions, they give you exhaustive, type-safe state modeling.
The problem with open hierarchies
Let’s say you’re modeling a network response:
open class Result {
class Success(val data: String) : Result()
class Error(val message: String) : Result()
class Loading : Result()
}
This compiles. But anyone can extend Result:
class UnexpectedResult : Result() // compiles fine
Your when block doesn’t know about UnexpectedResult, so it silently falls through. No compiler warning. No error. Just a bug waiting to happen.
Sealed classes fix this
A sealed class restricts which classes can extend it. All subclasses must be defined in the same file (or same package, since Kotlin 1.5).
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String, val code: Int) : Result()
data object Loading : Result()
}
Now no one outside this file can create a new subclass. The compiler knows every possible variant of Result.
Exhaustive when
This is where the real power is. When you use a when expression (not statement) on a sealed class, the compiler forces you to handle every case:
fun handleResult(result: Result): String = when (result) {
is Result.Success -> "Got: ${result.data}"
is Result.Error -> "Error ${result.code}: ${result.message}"
is Result.Loading -> "Loading..."
}
Try removing one branch — the code won’t compile:
fun handleResult(result: Result): String = when (result) {
is Result.Success -> "Got: ${result.data}"
is Result.Loading -> "Loading..."
// Error: 'when' expression must be exhaustive, add 'Error' branch or 'else'
}
This means: when you add a new state, the compiler tells you every place that needs to handle it. No runtime surprises. No forgotten branches.
Smart casting
Inside each when branch, Kotlin automatically casts the value to the matched type. No explicit casting needed:
fun handleResult(result: Result) {
when (result) {
is Result.Success -> {
// result is smart-cast to Result.Success here
println(result.data) // can access .data directly
}
is Result.Error -> {
// result is smart-cast to Result.Error here
println(result.message)
println(result.code)
}
is Result.Loading -> {
println("Loading...")
}
}
}
No (result as Result.Success).data. No casting. The compiler narrows the type inside each branch.
Sealed classes vs enums
Enums are great when every variant is a singleton — no data attached:
enum class Direction { NORTH, SOUTH, EAST, WEST }
But enums fall apart when variants need different data:
// Can't do this with enums
enum class Result {
SUCCESS(val data: String), // won't compile
ERROR(val message: String), // won't compile
LOADING
}
Sealed classes let each variant hold its own data:
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String, val code: Int) : Result()
data object Loading : Result()
}
| Feature | Enum | Sealed class |
|---|---|---|
| Fixed set of variants | Yes | Yes |
| Different data per variant | No | Yes |
Exhaustive when | Yes | Yes |
| Can be a singleton | Always | With data object |
| Can have abstract methods | Yes | Yes |
| Serialization | Built-in name | Needs configuration |
Rule of thumb: If every variant is the same shape (or no data), use enum. If variants carry different data, use sealed class.
Sealed interfaces
Since Kotlin 1.5, you can also use sealed interface. This is useful when you want a class to implement multiple sealed hierarchies:
sealed interface UiEvent
sealed interface AnalyticsEvent
data class ButtonClick(val buttonId: String) : UiEvent, AnalyticsEvent
data class ScreenView(val screenName: String) : UiEvent, AnalyticsEvent
data class PurchaseComplete(val amount: Double) : AnalyticsEvent
ButtonClick is both a UiEvent and an AnalyticsEvent. With sealed classes, this wouldn’t be possible (Kotlin has single inheritance).
Practical: Modeling UI state
The most common use in Android — representing screen state in a ViewModel:
sealed class ScreenState<out T> {
data object Loading : ScreenState<Nothing>()
data class Success<T>(val data: T) : ScreenState<T>()
data class Error(val message: String) : ScreenState<Nothing>()
data object Empty : ScreenState<Nothing>()
}
In the ViewModel:
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class UsersViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow<ScreenState<List<User>>>(ScreenState.Loading)
val state: StateFlow<ScreenState<List<User>>> = _state.asStateFlow()
fun loadUsers() {
viewModelScope.launch {
_state.value = ScreenState.Loading
try {
val users = repository.getUsers()
_state.value = if (users.isEmpty()) {
ScreenState.Empty
} else {
ScreenState.Success(users)
}
} catch (e: Exception) {
_state.value = ScreenState.Error(e.message ?: "Unknown error")
}
}
}
}
In the Activity:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state ->
when (state) {
is ScreenState.Loading -> showLoading()
is ScreenState.Success -> showUsers(state.data)
is ScreenState.Error -> showError(state.message)
is ScreenState.Empty -> showEmpty()
}
}
}
}
Every state is handled. If you add a new state variant later (say, ScreenState.Offline), the compiler flags every when block that needs updating.
Practical: Navigation events
For one-time events like navigation, sealed classes work well with SharedFlow:
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
sealed class NavigationEvent {
data class GoToDetail(val itemId: String) : NavigationEvent()
data class GoToProfile(val userId: String) : NavigationEvent()
data object GoBack : NavigationEvent()
data object GoToSettings : NavigationEvent()
}
class MainViewModel : ViewModel() {
private val _navigation = MutableSharedFlow<NavigationEvent>()
val navigation: SharedFlow<NavigationEvent> = _navigation.asSharedFlow()
fun onItemClicked(itemId: String) {
viewModelScope.launch {
_navigation.emit(NavigationEvent.GoToDetail(itemId))
}
}
}
Practical: API response wrapper
Wrapping API responses so the calling code doesn’t need try-catch everywhere:
sealed class ApiResponse<out T> {
data class Success<T>(val data: T) : ApiResponse<T>()
data class HttpError(val code: Int, val message: String) : ApiResponse<Nothing>()
data class NetworkError(val throwable: Throwable) : ApiResponse<Nothing>()
}
suspend fun <T> safeApiCall(block: suspend () -> T): ApiResponse<T> {
return try {
ApiResponse.Success(block())
} catch (e: HttpException) {
ApiResponse.HttpError(e.code(), e.message())
} catch (e: IOException) {
ApiResponse.NetworkError(e)
}
}
Usage:
val response = safeApiCall { api.getUsers() }
when (response) {
is ApiResponse.Success -> showUsers(response.data)
is ApiResponse.HttpError -> showHttpError(response.code, response.message)
is ApiResponse.NetworkError -> showNetworkError()
}
The compiler ensures you handle both HTTP errors and network errors — not just one.
Nested sealed classes
For complex state machines, you can nest sealed classes:
sealed class PaymentState {
data object Idle : PaymentState()
sealed class Processing : PaymentState() {
data object ValidatingCard : Processing()
data object ChargingCard : Processing()
data object WaitingForConfirmation : Processing()
}
data class Completed(val transactionId: String) : PaymentState()
sealed class Failed : PaymentState() {
data class CardDeclined(val reason: String) : Failed()
data class NetworkError(val throwable: Throwable) : Failed()
data object Timeout : Failed()
}
}
You can match at any level of specificity:
fun handlePayment(state: PaymentState) {
when (state) {
is PaymentState.Idle -> showIdle()
is PaymentState.Processing -> showSpinner() // matches all Processing subtypes
is PaymentState.Completed -> showReceipt(state.transactionId)
is PaymentState.Failed -> showError() // matches all Failed subtypes
}
}
// Or be more specific when needed
fun handleFailure(failure: PaymentState.Failed) {
when (failure) {
is PaymentState.Failed.CardDeclined -> showCardError(failure.reason)
is PaymentState.Failed.NetworkError -> showRetryButton()
is PaymentState.Failed.Timeout -> showTimeoutMessage()
}
}
Common mistakes
1. Using else with sealed classes
// Don't do this
when (result) {
is Result.Success -> handleSuccess(result.data)
else -> handleError() // hides new variants
}
The whole point of sealed classes is exhaustive checking. Adding else defeats that — when you add a new variant, the compiler won’t warn you. Only use else if you genuinely want to treat all other variants the same way.
2. Forgetting data class
// Missing data class — no equals/hashCode/copy/toString
sealed class Result {
class Success(val data: String) : Result()
}
// With data class — you get all of those for free
sealed class Result {
data class Success(val data: String) : Result()
}
Without data class, two Success("hello") instances aren’t equal. This matters when using StateFlow (which uses equals() to detect changes) or when comparing states in tests.
3. Using sealed class when enum is enough
// Over-engineered — these are all singletons
sealed class Theme {
data object Light : Theme()
data object Dark : Theme()
data object System : Theme()
}
// Just use an enum
enum class Theme { LIGHT, DARK, SYSTEM }
If no variant carries unique data, an enum is simpler and more idiomatic.
Summary
Sealed classes give you:
- Restricted hierarchy — no unexpected subclasses
- Exhaustive
when— compiler catches unhandled cases - Smart casting — no manual type casting inside branches
- Different data per variant — unlike enums
Use them for UI state, API responses, navigation events, and anywhere you have a fixed set of types with different shapes. Pair them with when expressions, and the compiler becomes your safety net.