DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Kotlin Sealed Classes — Type-Safe State, Result & Navigation Patterns

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()
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

If you add object ApplePay : Payment(), every when statement becomes a compiler error until updated—no silent bugs.

Best Practices

  1. Use object for stateless cases (Loading, Error) to avoid object creation overhead
  2. Use data class for cases with data (Success, Detail)
  3. Keep sealed class simple—complex logic goes in extension functions
  4. Prefer sealed interfaces for flexibility (can implement multiple interfaces)
  5. Always use exhaustive when on sealed types—never add else

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

8 Android App Templates ready to customize → https://myougatheax.gumroad.com

Top comments (0)