Kotlin Sealed Classes and Pattern Matching
Introduction
Kotlin's sealed classes and interfaces represent one of the most powerful type-system features available to Android developers. They allow you to express constrained hierarchies where all possible subtypes are known at compile time. When combined with exhaustive when expressions, they eliminate entire categories of runtime bugs that plague traditional if-else chains and plain inheritance hierarchies. This tutorial walks through how sealed types work, how to model real Android UI state with them, and how to systematically refactor brittle conditional logic into expressive, type-safe code. Every example uses current Kotlin and Android practices you can drop into a live project.
What Are Sealed Classes?
A sealed class declares that its direct subclasses must live in the same compilation unit. In practice, this means the compiler knows every possible concrete type, which unlocks exhaustiveness checking. Without sealed types, a base class or interface can be implemented by anyone, anywhere—your when or if-else chains can never prove they've covered every case.
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val message: String, val code: Int? = null) : NetworkResult<Nothing>()
object Loading : NetworkResult<Nothing>()
}
fun <T> handleResult(result: NetworkResult<T>) {
when (result) {
is NetworkResult.Success -> println("Got data: ${result.data}")
is NetworkResult.Error -> println("Failed: ${result.message} (${result.code})")
is NetworkResult.Loading -> println("Waiting...")
}
}
This example defines a generic NetworkResult with three subtypes: Success, Error, and Loading. Because the class is sealed, the when expression inside handleResult is exhaustive—the compiler guarantees no branch is missing. At runtime, is checks dispatch to the matching branch efficiently. The out variance on T lets Loading and Error exist as NetworkResult<Nothing> without breaking covariance, which mirrors how real network APIs model transient states.
Exhaustive When Expressions
The compiler enforces exhaustiveness whenever a when is used as an expression (meaning it returns a value) against a sealed type. If you omit any subtype, compilation fails. This turns a class of logical errors into a compile-time guarantee.
sealed class UiAction {
data class Navigate(val route: String) : UiAction()
data class ShowSnackbar(val message: String) : UiAction()
object DismissDialog : UiAction()
}
fun processAction(action: UiAction): String {
return when (action) {
is UiAction.Navigate -> "Navigating to ${action.route}"
is UiAction.ShowSnackbar -> "Showing snackbar: ${action.message}"
is UiAction.DismissDialog -> "Dialog dismissed"
}
}
Because UiAction is sealed, the when block must cover every subtype, or the code won't compile. Notice there is no else branch required—Kotlin's smart casts let you access properties like action.route and action.message directly inside each branch. This pattern scales cleanly: adding a new subtype like UiAction.Logout would cause a compile error until every when over UiAction is updated, making regression-safe refactoring possible.
Modelling UI State with Sealed Classes
One of the most common Android patterns is representing screen state as a sealed class hierarchy. Jetpack Compose and View-based architectures alike benefit from this approach because it makes invalid states unreachable and forces handlers to consider every transition explicitly.
sealed class ArticleScreenState {
data class Idle(val articles: List<String> = emptyList()) : ArticleScreenState()
data class Loading(val progress: Float = 0f) : ArticleScreenState()
data class Loaded(
val articles: List<String>,
val selected: String? = null
) : ArticleScreenState()
data class Error(
val message: String,
val retryCount: Int = 0
) : ArticleScreenState()
}
fun simulateStateTransition(state: ArticleScreenState): ArticleScreenState {
return when (state) {
is ArticleScreenState.Idle -> ArticleScreenState.Loading()
is ArticleScreenState.Loading -> ArticleScreenState.Loaded(listOf("Art 1", "Art 2"))
is ArticleScreenState.Loaded -> state.copy(selected = state.articles.firstOrNull())
is ArticleScreenState.Error -> if (state.retryCount < 3) {
ArticleScreenState.Loading()
} else {
state
}
}
}
Here ArticleScreenState captures four mutually exclusive screen conditions. The simulateStateTransition function demonstrates how sealed classes make state machines expressible without flags or magic numbers. Each branch returns a concrete subtype, and the compiler ensures no state is forgotten. The Loaded state carries both a list and an optional selection, showing how data classes inside sealed hierarchies can hold rich payloads without losing type safety.
Refactoring Long If-Else Chains into Types
Long if-else chains are a smell: they grow unbounded, encourage duplicated conditions, and hide intent. Sealed classes let you extract the conditional logic into types themselves, moving behavior closer to data.
sealed class PaymentStatus {
data class Pending(val amount: Double, val currency: String) : PaymentStatus()
data class Completed(val transactionId: String, val amount: Double) : PaymentStatus()
data class Failed(val reason: String, val amount: Double) : PaymentStatus()
object Refunded : PaymentStatus()
}
fun formatPaymentSummary(status: PaymentStatus): String {
return when (status) {
is PaymentStatus.Pending -> "Pending ${status.amount} ${status.currency}"
is PaymentStatus.Completed -> "Paid ${status.transactionId} — ${status.amount}"
is PaymentStatus.Failed -> "Failed: ${status.reason} (${status.amount})"
is PaymentStatus.Refunded -> "Refunded"
}
}
// Before: fragile if-else chain
fun oldFormat(type: Int, amount: Double, reason: String?): String {
if (type == 0) return "Pending $amount"
if (type == 1) return "Paid txn-123 — $amount"
if (type == 2) return "Failed: $reason ($amount)"
if (type == 3) return "Refunded"
throw IllegalArgumentException("Unknown type: $type")
}
The refactored formatPaymentSummary replaces the magic-integers approach entirely. Each payment outcome is a first-class type with its own payload, so there is no risk of passing the wrong argument position or forgetting a case. The oldFormat function illustrates the anti-pattern: it relies on integer constants, lacks compile-time coverage guarantees, and throws at runtime instead of failing at compile time. Replacing such functions with sealed-type dispatch is often a single refactor pass away.
Sealed Interfaces
Sealed interfaces extend the same exhaustiveness guarantee to interfaces, which is valuable when you want multiple unrelated classes to share a contract while keeping their subtypes bounded. This is especially useful in modular Android projects where different modules contribute implementations.
sealed interface Event {
data class UserAction(val name: String, val timestamp: Long) : Event
data class SystemEvent(val code: Int, val payload: Map<String, Any?>) : Event
object Heartbeat : Event
}
fun logEvent(event: Event) {
when (event) {
is Event.UserAction -> println("[User] ${event.name} at ${event.timestamp}")
is Event.SystemEvent -> println("[System] code=${event.code} payload=${event.payload}")
is Event.Heartbeat -> println("[System] heartbeat")
}
}
Event is a sealed interface, so any class implementing it must be in the same file or module scope. The logEvent function benefits from full exhaustiveness even though Event is an interface rather than a class. This pattern is common in analytics and event-bus designs where you want a closed set of event shapes but don't want to force a single inheritance chain. Note that sealed interfaces cannot be implemented by anonymous inner classes or lambda expressions, which keeps the subtype boundary strict.
Common Pitfalls
Sealed classes are easy to misuse if you're not careful about scope, visibility, and design boundaries. The most frequent mistake is placing subtypes in a different file without ensuring they share the same package or module, which silently breaks exhaustiveness. Another pitfall is using sealed classes as general-purpose polymorphism substitutes—they shine for finite state sets but become awkward when the hierarchy needs to grow dynamically, such as plugin-based architectures loaded at runtime.
// BAD: subclass in a different file/module loses exhaustiveness
// sealed class Color ... (in colors.kt)
// class Red : Color() (in utils.kt, different package)
// GOOD: keep subtypes co-located or in the same module
sealed class ThemeMode {
object Light : ThemeMode()
object Dark : ThemeMode()
object System : ThemeMode()
}
fun getThemeLabel(mode: ThemeMode): String = when (mode) {
is ThemeMode.Light -> "Light"
is ThemeMode.Dark -> "Dark"
is ThemeMode.System -> "System"
}
The incorrect example above shows what happens when a subtype is defined outside the sealed class's compilation scope: the compiler no longer treats the hierarchy as closed, when requires an else branch, and you lose the safety guarantee. The correct example keeps all subtypes inside the sealed declaration, preserving exhaustiveness. A related issue is overloading sealed classes with too many responsibilities—each subtype should represent a single, coherent concept, not a grab-bag of unrelated fields.
Conclusion
Sealed classes and interfaces are among Kotlin's most practical features for Android development. They turn runtime uncertainty into compile-time certainty, making UI state modelling, event handling, and state machine logic both safer and more readable. By replacing long if-else chains with typed hierarchies, you gain exhaustive checking, smarter casts, and a structure that grows predictably as your app evolves. Keep subtypes co-located, prefer data classes for payload-bearing branches, and let the compiler enforce what you would otherwise enforce manually. The result is code that is easier to test, easier to refactor, and harder to break.