Kotlin Sealed Classes — Type-Safe State, Result & Navigation Patterns
Sealed classes are one of Kotlin's most powerful features for building type-safe, exhaustive systems. They enforce compile-time guarantees that all possible cases are handled, making your code more robust and maintainable.
What Are Sealed Classes?
A sealed class is an abstract class that restricts which subclasses can extend it. All subclasses must be defined in the same file (or same package in Kotlin 1.1+), preventing unknown subclass proliferation.
sealed class ApiResponse {
data class Success(val data: String) : ApiResponse()
data class Error(val exception: Exception) : ApiResponse()
object Loading : ApiResponse()
}
This prevents anyone from creating a fourth unknown subclass—the compiler knows all possibilities upfront.
Pattern 1: UiState for UI Layer
The most common use case is modeling screen states. Instead of multiple nullable properties (title, error, isLoading), use a sealed class:
sealed class UiState {
object Loading : UiState()
data class Success(val items: List<Item>) : UiState()
data class Error(val message: String) : UiState()
}
// ViewModel
class ItemViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
}
// UI consumption with exhaustive when
when (uiState) {
is UiState.Loading -> ShowLoadingSpinner()
is UiState.Success -> ItemList(uiState.items)
is UiState.Error -> ErrorMessage(uiState.message)
// Compiler error if you forget a case!
}
Pattern 2: Result Type for Network Operations
Replace try-catch with a functional Result type:
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Failure(val exception: Exception) : Result<Nothing>()
object Loading : Result<Nothing>()
}
// Usage
suspend fun fetchUser(id: String): Result<User> {
return try {
val user = apiService.getUser(id)
Result.Success(user)
} catch (e: Exception) {
Result.Failure(e)
}
}
// Composition
val result = fetchUser("123")
when (result) {
is Result.Success -> updateUI(result.data)
is Result.Failure -> showError(result.exception.message)
is Result.Loading -> showSpinner()
}
Pattern 3: Sealed Interface for Navigation
Jetpack Compose navigation works beautifully with sealed classes:
sealed interface Screen {
@Serializable
data object Home : Screen
@Serializable
data class Detail(val id: String) : Screen
@Serializable
data class Edit(val itemId: String, val mode: String) : Screen
}
// NavController
navController.navigate(Screen.Detail(id = "42"))
// NavHost with type safety
NavHost(navController, startDestination = Screen.Home) {
composable<Screen.Home> { HomeScreen(navController) }
composable<Screen.Detail> { backStackEntry ->
val args = backStackEntry.toRoute<Screen.Detail>()
DetailScreen(itemId = args.id, navController)
}
}
Pattern 4: UiEvent with Sealed Classes & Channel
Model one-time events (toast, navigation) separately from state:
sealed class UiEvent {
data class ShowToast(val message: String) : UiEvent()
data class Navigate(val destination: String) : UiEvent()
object DismissSheet : UiEvent()
}
class MyViewModel : ViewModel() {
private val _event = Channel<UiEvent>()
val event = _event.receiveAsFlow()
fun deleteItem(id: String) {
viewModelScope.launch {
try {
repository.delete(id)
_event.send(UiEvent.ShowToast("Deleted!"))
} catch (e: Exception) {
_event.send(UiEvent.ShowToast("Error: ${e.message}"))
}
}
}
}
// Collect in UI
LaunchedEffect(Unit) {
viewModel.event.collect { event ->
when (event) {
is UiEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
is UiEvent.Navigate -> {
navController.navigate(event.destination)
}
is UiEvent.DismissSheet -> {
// Handle sheet dismissal
}
}
}
}
Pattern 5: Exhaustive when Compiler Verification
The killer feature: Kotlin's when expression becomes exhaustive when checking a sealed type. Add a new subclass, and the compiler fails everywhere you forgot to handle it:
sealed class Payment {
object Cash : Payment()
data class Card(val last4: String) : Payment()
data class Crypto(val address: String) : Payment()
}
fun processPayment(payment: Payment): String = when (payment) {
is Payment.Cash -> "Accept cash"
is Payment.Card -> "Charge ${payment.last4}"
is Payment.Crypto -> "Send invoice to ${payment.address}"
// No need for else! Compiler ensures all cases covered.
}
If you add object ApplePay : Payment(), every when statement becomes a compiler error until updated—no silent bugs.
Best Practices
-
Use
objectfor stateless cases (Loading, Error) to avoid object creation overhead -
Use
data classfor cases with data (Success, Detail) - Keep sealed class simple—complex logic goes in extension functions
- Prefer sealed interfaces for flexibility (can implement multiple interfaces)
-
Always use exhaustive
whenon sealed types—never addelse
Real-World Example: Complete Flow
sealed class LoginState {
object Idle : LoginState()
object Loading : LoginState()
data class Success(val user: User) : LoginState()
data class Error(val message: String) : LoginState()
}
class AuthViewModel(private val authRepo: AuthRepository) : ViewModel() {
private val _loginState = MutableStateFlow<LoginState>(LoginState.Idle)
val loginState = _loginState.asStateFlow()
fun login(email: String, password: String) {
viewModelScope.launch {
_loginState.value = LoginState.Loading
val result = authRepo.login(email, password)
_loginState.value = when (result) {
is Result.Success -> LoginState.Success(result.data)
is Result.Failure -> LoginState.Error(result.exception.message ?: "Unknown error")
}
}
}
}
// UI with Compose
@Composable
fun LoginScreen(viewModel: AuthViewModel) {
val loginState by viewModel.loginState.collectAsState()
when (loginState) {
is LoginState.Idle -> LoginForm(onSubmit = { email, pass ->
viewModel.login(email, pass)
})
is LoginState.Loading -> LoadingIndicator()
is LoginState.Success -> {
LaunchedEffect(Unit) {
navController.navigate(Screen.Home)
}
}
is LoginState.Error -> ErrorDialog((loginState as LoginState.Error).message)
}
}
8 Android App Templates ready to customize → https://myougatheax.gumroad.com
Top comments (0)