Sealed classes are one of the most powerful and most used features in modern Android development. They let you represent a restricted set of possibilities — like the different states a screen can be in, or the different outcomes of a network call. This guide explains everything from scratch with practical examples you'll recognize from real apps.


What Problem Do Sealed Classes Solve?

Imagine you're loading data from an API. The result can be one of three things:

  • Loading — request is in progress
  • Success — data came back
  • Error — something went wrong

You need a type that can represent exactly these three states — no more, no less. This is exactly what sealed classes are for.


What Is a Sealed Class?

A sealed class is a class that restricts which other classes can extend it. All subclasses must be defined in the same file. This gives the compiler complete knowledge of all possible subtypes.

sealed class Result {
    object Loading : Result()
    data class Success(val data: String) : Result()
    data class Error(val message: String) : Result()
}

Now Result can only ever be Loading, Success, or Error. Nothing else. The compiler knows this — and that's what makes sealed classes powerful.

Real-world analogy: Think of a traffic light. It can only be Red, Yellow, or Green — never Purple or Blinking. A sealed class is like a traffic light — a fixed, known set of states.


Sealed Class vs Enum vs Regular Class

Before going deeper, let's understand why sealed classes exist alongside enums:

Feature Enum Sealed Class
Fixed set of values
Each subtype can have different data
Each subtype can have different functions
Compiler exhaustive check with when
Can be a data class
Can hold state Limited Full flexibility
// Enum — every value is the same shape, no different data
enum class Direction { NORTH, SOUTH, EAST, WEST }

// Sealed class — each subtype can have completely different structure
sealed class Shape {
    data class Circle(val radius: Double) : Shape()
    data class Rectangle(val width: Double, val height: Double) : Shape()
    object Unknown : Shape()
}
Use enums for simple, uniform values. Use sealed classes when each case needs its own data or behavior.

Declaring a Sealed Class

sealed class NetworkResult<out T> {
    object Loading : NetworkResult<Nothing>()
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Error(val message: String, val code: Int? = null) : NetworkResult<Nothing>()
}

Subclasses of a sealed class can be:

  • object — for states with no data (like Loading)
  • data class — for states with data (like Success or Error)
  • Regular class — when you need mutable state or complex behavior

The Real Power — Exhaustive when

When you use when with a sealed class, the compiler checks that you've handled every possible case. If you add a new subtype later, every when that doesn't handle it will show a compile error. No runtime surprises.

sealed class UiState {
    object Loading : UiState()
    data class Success(val articles: List<Article>) : UiState()
    data class Error(val message: String) : UiState()
    object Empty : UiState()
}

fun handleState(state: UiState) {
    when (state) {
        is UiState.Loading  -> showLoading()
        is UiState.Success  -> showArticles(state.articles)
        is UiState.Error    -> showError(state.message)
        is UiState.Empty    -> showEmptyView()
        // No else needed — compiler knows all cases are covered
    }
}

If you add object Refreshing : UiState() later and forget to handle it in when, the compiler immediately warns you. This prevents bugs at compile time instead of runtime.


Most Common Use Case — UI State

This is the pattern you'll see in almost every modern Android codebase:

sealed class ArticleUiState {
    object Loading : ArticleUiState()
    object Empty : ArticleUiState()
    data class Success(val articles: List<Article>) : ArticleUiState()
    data class Error(val message: String, val retryable: Boolean = true) : ArticleUiState()
}

class ArticleViewModel(private val repository: ArticleRepository) : ViewModel() {

    private val _uiState = MutableStateFlow<ArticleUiState>(ArticleUiState.Loading)
    val uiState: StateFlow<ArticleUiState> = _uiState

    fun loadArticles() {
        _uiState.value = ArticleUiState.Loading

        viewModelScope.launch {
            try {
                val articles = repository.getArticles()

                _uiState.value = if (articles.isEmpty()) {
                    ArticleUiState.Empty
                } else {
                    ArticleUiState.Success(articles)
                }
            } catch (e: Exception) {
                _uiState.value = ArticleUiState.Error(
                    message = e.message ?: "Something went wrong",
                    retryable = true
                )
            }
        }
    }
}

In the Fragment — handling each state:

class ArticleFragment : Fragment() {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.uiState.collect { state ->
                when (state) {
                    is ArticleUiState.Loading -> {
                        progressBar.visibility = View.VISIBLE
                        recyclerView.visibility = View.GONE
                        errorLayout.visibility = View.GONE
                        emptyLayout.visibility = View.GONE
                    }
                    is ArticleUiState.Success -> {
                        progressBar.visibility = View.GONE
                        recyclerView.visibility = View.VISIBLE
                        errorLayout.visibility = View.GONE
                        emptyLayout.visibility = View.GONE
                        adapter.submitList(state.articles)
                    }
                    is ArticleUiState.Error -> {
                        progressBar.visibility = View.GONE
                        recyclerView.visibility = View.GONE
                        errorLayout.visibility = View.VISIBLE
                        emptyLayout.visibility = View.GONE
                        errorMessage.text = state.message
                        retryButton.visibility = if (state.retryable) View.VISIBLE else View.GONE
                    }
                    is ArticleUiState.Empty -> {
                        progressBar.visibility = View.GONE
                        recyclerView.visibility = View.GONE
                        errorLayout.visibility = View.GONE
                        emptyLayout.visibility = View.VISIBLE
                    }
                }
            }
        }
    }
}

Second Most Common Use Case — API Result Wrapper

Wrap every API call result in a sealed class to handle success and failure consistently across your app:

sealed class ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>()
    data class Error(
        val message: String,
        val code: Int? = null,
        val exception: Exception? = null
    ) : ApiResult<Nothing>()
    object Loading : ApiResult<Nothing>()
}

// Reusable safe API call function
suspend fun <T> safeApiCall(call: suspend () -> T): ApiResult<T> {
    return try {
        ApiResult.Success(call())
    } catch (e: IOException) {
        ApiResult.Error("No internet connection", exception = e)
    } catch (e: HttpException) {
        ApiResult.Error("Server error", code = e.code(), exception = e)
    } catch (e: Exception) {
        ApiResult.Error(e.message ?: "Unknown error", exception = e)
    }
}

// In repository
class UserRepository(private val apiService: ApiService) {

    suspend fun getUser(id: String): ApiResult<User> {
        return safeApiCall { apiService.getUser(id) }
    }

    suspend fun updateProfile(user: User): ApiResult<User> {
        return safeApiCall { apiService.updateProfile(user) }
    }
}

// In ViewModel
viewModelScope.launch {
    when (val result = repository.getUser(userId)) {
        is ApiResult.Loading -> showLoading()
        is ApiResult.Success -> showUser(result.data)
        is ApiResult.Error   -> showError(result.message)
    }
}

Sealed Classes for Navigation Events

sealed class NavigationEvent {
    object GoBack : NavigationEvent()
    data class GoToArticle(val articleId: String) : NavigationEvent()
    data class GoToProfile(val userId: String) : NavigationEvent()
    data class OpenUrl(val url: String) : NavigationEvent()
    object GoToLogin : NavigationEvent()
}

class HomeViewModel : ViewModel() {

    private val _navigationEvent = MutableSharedFlow<NavigationEvent>()
    val navigationEvent = _navigationEvent.asSharedFlow()

    fun onArticleClicked(articleId: String) {
        viewModelScope.launch {
            _navigationEvent.emit(NavigationEvent.GoToArticle(articleId))
        }
    }

    fun onProfileClicked(userId: String) {
        viewModelScope.launch {
            _navigationEvent.emit(NavigationEvent.GoToProfile(userId))
        }
    }

    fun onLogoutClicked() {
        viewModelScope.launch {
            authService.logout()
            _navigationEvent.emit(NavigationEvent.GoToLogin)
        }
    }
}

// In Fragment
viewLifecycleOwner.lifecycleScope.launch {
    viewModel.navigationEvent.collect { event ->
        when (event) {
            is NavigationEvent.GoBack           -> findNavController().popBackStack()
            is NavigationEvent.GoToArticle      -> navigateToArticle(event.articleId)
            is NavigationEvent.GoToProfile      -> navigateToProfile(event.userId)
            is NavigationEvent.OpenUrl          -> openBrowser(event.url)
            is NavigationEvent.GoToLogin        -> navigateToLogin()
        }
    }
}

Sealed Classes for Form Validation

sealed class ValidationResult {
    object Valid : ValidationResult()
    data class Invalid(val reason: String) : ValidationResult()
}

fun validateEmail(email: String): ValidationResult {
    return when {
        email.isBlank()        -> ValidationResult.Invalid("Email cannot be empty")
        !email.contains("@")   -> ValidationResult.Invalid("Email must contain @")
        !email.contains(".")   -> ValidationResult.Invalid("Email must contain a domain")
        email.length < 5       -> ValidationResult.Invalid("Email is too short")
        else                   -> ValidationResult.Valid
    }
}

fun validatePassword(password: String): ValidationResult {
    return when {
        password.isBlank()                      -> ValidationResult.Invalid("Password cannot be empty")
        password.length < 8                     -> ValidationResult.Invalid("At least 8 characters required")
        !password.any { it.isDigit() }          -> ValidationResult.Invalid("Must contain a number")
        !password.any { it.isUpperCase() }      -> ValidationResult.Invalid("Must contain an uppercase letter")
        else                                    -> ValidationResult.Valid
    }
}

// Usage
val emailResult = validateEmail(emailInput.text.toString())
when (emailResult) {
    is ValidationResult.Valid   -> emailLayout.error = null
    is ValidationResult.Invalid -> emailLayout.error = emailResult.reason
}

Sealed Interfaces — Kotlin 1.5+

Since Kotlin 1.5, you can also use sealed interface. The difference from sealed class is that a class can implement multiple sealed interfaces but can only extend one sealed class.

sealed interface Action {
    object Refresh : Action
    data class Search(val query: String) : Action
    data class Filter(val category: String) : Action
    object LoadMore : Action
}

// A class can implement multiple sealed interfaces
class CompositeAction : Action, AnotherSealedInterface {
    // ...
}

Sealed interfaces are useful when you need multiple inheritance — a class being part of more than one sealed hierarchy.


Adding Shared Behavior to Sealed Classes

You can add functions and properties to the sealed class itself — all subtypes inherit them:

sealed class PaymentMethod {
    abstract val name: String
    abstract val fee: Double

    fun displayInfo(): String = "$name (fee: ${fee * 100}%)"

    data class CreditCard(val last4Digits: String) : PaymentMethod() {
        override val name = "Credit Card"
        override val fee = 0.029
    }

    data class PayPal(val email: String) : PaymentMethod() {
        override val name = "PayPal"
        override val fee = 0.034
    }

    object Cash : PaymentMethod() {
        override val name = "Cash"
        override val fee = 0.0
    }
}

val method: PaymentMethod = PaymentMethod.CreditCard("4242")
println(method.displayInfo())   // Credit Card (fee: 2.9%)
println(method.fee)             // 0.029

val payment = PaymentMethod.PayPal("user@email.com")
println(payment.displayInfo())  // PayPal (fee: 3.4%)

Common Mistakes to Avoid

Mistake 1: Using else in when with sealed class

sealed class State { object A : State(); object B : State() }

// ❌ Using else — defeats the purpose
when (state) {
    is State.A -> handleA()
    else -> handleB()  // if you add State.C, compiler won't warn you
}

// ✅ Handle all cases explicitly
when (state) {
    is State.A -> handleA()
    is State.B -> handleB()
    // compiler now warns if you add State.C and forget to handle it
}

Mistake 2: Defining subclasses outside the file

// File: State.kt
sealed class State

// File: OtherFile.kt
class StateC : State()  // ❌ compile error — must be in same file

Mistake 3: Using sealed class when enum is enough

// ❌ Overkill — no different data per type needed
sealed class Direction {
    object North : Direction()
    object South : Direction()
    object East : Direction()
    object West : Direction()
}

// ✅ Enum is perfect here
enum class Direction { NORTH, SOUTH, EAST, WEST }

Mistake 4: Forgetting smart cast

sealed class Result { data class Success(val data: String) : Result() }

val result: Result = Result.Success("hello")

// ❌ Unnecessary cast
if (result is Result.Success) {
    println((result as Result.Success).data)
}

// ✅ Smart cast — no manual cast needed
if (result is Result.Success) {
    println(result.data)  // result is auto-cast to Result.Success
}

Summary

  • A sealed class restricts which classes can extend it — all subclasses must be in the same file
  • The compiler knows all possible subtypes — giving you exhaustive when checks
  • Subclasses can be object (no data), data class (with data), or regular class
  • Adding a new subtype causes compile errors everywhere you forgot to handle it — catches bugs early
  • Most common uses: UI state, API results, navigation events, validation results
  • Sealed classes differ from enums because each subtype can have different data and behavior
  • Use sealed interface when you need a class to belong to multiple sealed hierarchies
  • Always handle every case explicitly in when — never use else with sealed classes
  • Smart cast works inside when branches — no manual casting needed

Sealed classes are one of the features that makes Kotlin code dramatically safer and more maintainable than Java. Once you start using them for UI state and API results, you'll wonder how you ever wrote Android apps without them.

Happy coding!