Understanding the Boundary Between Sealed Interfaces and Sealed Classes
If you’re building a well-typed Kotlin codebase, you’ll encounter moments where you need to restrict which types can inherit from a base type. Sealed classes have been the go-to for this for years, but Kotlin 1.5 introduced sealed interfaces — and they’re better in subtle but important ways. The question isn’t “which is correct?”; it’s “which solves your specific inheritance problem?” This post breaks down the actual differences, shows you real patterns from the Android and Kotlin ecosystems, and gives you rules for choosing between them.
The core distinction: a sealed interface allows multi-hierarchy (a class can implement multiple sealed interfaces), while a sealed class can only have one parent class. That difference ripples through your design in ways that matter.

1. State and Constructor Parameters
Sealed classes can hold state and have constructors. Your subclasses inherit properties and initialization logic automatically:
sealed class Result(val timestamp: Long = System.currentTimeMillis()) {
abstract val isSuccess: Boolean
}
data class Success(val data: T) : Result() {
override val isSuccess = true
}
data class Failure(val exception: Exception) : Result() {
override val isSuccess = false
}
Every subclass automatically inherits timestamp, initialized once in the base constructor — no per-subclass override required. That kind of shared stored state is something sealed interfaces cannot express.
Sealed interfaces are purely contractual. They can’t hold stored state or run initialization logic — every property must be re-declared and initialized in each implementor:
sealed interface Result {
val timestamp: Long
val isSuccess: Boolean
}
data class Success(val data: T) : Result {
override val timestamp = System.currentTimeMillis()
override val isSuccess = true
}
data class Failure(val exception: Exception) : Result {
override val timestamp = System.currentTimeMillis()
override val isSuccess = false
}
If you have shared logic or state, use a sealed class. If you only need to define a contract, use a sealed interface.
2. Multiple Inheritance (The Key Difference)
This is the killer feature of sealed interfaces. A class can implement multiple sealed interfaces, but can only inherit from one sealed class:
sealed interface Drawable {
fun draw()
}
sealed interface Serializable {
fun toJson(): String
}
data class Button(val label: String) : Drawable, Serializable {
override fun draw() = println("Drawing button: $label")
override fun toJson() = "{\"type\": \"button\", \"label\": \"$label\"}"
}
// With sealed classes, this is impossible:
sealed class UIElement {
abstract fun draw()
}
sealed class Persistable {
abstract fun toJson(): String
}
// ERROR: Cannot inherit from two sealed classes
class IconButton(val icon: String) : UIElement(), Persistable()
This multi-hierarchy capability is why modern Kotlin libraries prefer sealed interfaces for cross-cutting concerns.
3. Performance and Compilation
Both sealed classes and sealed interfaces are resolved at compile time. The compiler tracks permitted subclasses through metadata annotations in the bytecode — this applies equally to both. There is no meaningful runtime performance difference between them, so your choice should be driven entirely by design constraints (state sharing vs. multi-hierarchy), not performance.
Sealed Interfaces in the Kotlin and Android Ecosystem
The Result pattern — Kotlin’s standard library kotlin.Result is actually an inline (value) class, not a sealed type. However, the sealed interface version of Result is one of the most common patterns in Android codebases. Libraries like Arrow define their own Either<L, R> as a sealed type, and many teams build custom Result wrappers using sealed interfaces for maximum flexibility:
sealed interface Result
data class Success(val value: T) : Result
data class Failure(val exception: Exception) : Result
fun handleResult(result: Result) = when (result) {
is Success -> println("Got: ${result.value}")
is Failure -> println("Error: ${result.exception.message}")
}
Using a sealed interface here (rather than a sealed class) means your domain types can implement Result alongside other interfaces — for example, a response object that is both a Result and Parcelable.
Compose input events — When you build a custom event system on top of Jetpack Compose’s pointer input, sealed interfaces let a single event class participate in multiple restricted hierarchies without the inheritance conflicts you’d hit with sealed classes:
sealed interface UserInputEvent {
val timestamp: Long
}
sealed interface Trackable {
val analyticsName: String
}
data class ClickEvent(
val x: Float,
val y: Float,
override val timestamp: Long,
override val analyticsName: String = "click"
) : UserInputEvent, Trackable
data class SwipeEvent(
val deltaX: Float,
val deltaY: Float,
override val timestamp: Long,
override val analyticsName: String = "swipe"
) : UserInputEvent, Trackable
Multi-interface composition — Sealed interfaces bring the same layered-contract flexibility that coroutines already rely on (Deferred<T> extends Job, which extends CoroutineContext.Element) — but with the added benefit of exhaustiveness checking in when expressions that plain interfaces don’t give you. Your own types can belong to several restricted hierarchies at once while the compiler still verifies you’ve handled every case.
Decision Matrix: When to Use Each
Use sealed class when: Your types share common state or properties, you want subclasses to inherit initialization logic, or you need a single strong inheritance hierarchy with shared behavior.
Use sealed interface when: You’re defining a contract only, you need multiple inheritance, you want loose coupling, or you’re designing cross-cutting concerns (logging, serialization, events).
Pattern 1: UI State (Sealed Interface — The Simple Case)
Most UI state hierarchies don’t actually share state between subtypes. Each state carries its own payload, and the when expression handles exhaustiveness. A sealed interface is the idiomatic choice here:
sealed interface UiState {
data object Loading : UiState
data class Success(val data: T) : UiState
data class Error(val message: String) : UiState
}
fun render(state: UiState) = when (state) {
is UiState.Loading -> showSpinner()
is UiState.Success -> showContent(state.data)
is UiState.Error -> showError(state.message)
}
This is how most Android teams model screen state today. Use a sealed class instead only if you genuinely have shared initialization logic or common mutable state across all subtypes — for example, a base timestamp set in the constructor that every subtype inherits without overriding.
Pattern 2: Navigation Events (Sealed Interface)
Navigation events often carry different payloads. Use a sealed interface for flexibility:
sealed interface NavigationEvent {
val timestamp: Long
}
data class NavigateTo(val destination: String, override val timestamp: Long = System.currentTimeMillis()) : NavigationEvent
data class Pop(override val timestamp: Long = System.currentTimeMillis()) : NavigationEvent
data class PopTo(val route: String, override val timestamp: Long = System.currentTimeMillis()) : NavigationEvent
data class Replace(val destination: String, override val timestamp: Long = System.currentTimeMillis()) : NavigationEvent
fun handleNavigation(event: NavigationEvent) = when (event) {
is NavigateTo -> println("Navigate to ${event.destination}")
is Pop -> println("Pop stack")
is PopTo -> println("Pop to ${event.route}")
is Replace -> println("Replace with ${event.destination}")
}
Pattern 3: Error Hierarchies (Sealed Class for Shared Handling)
If different error types share recovery logic, use a sealed class:
sealed class AppError(message: String, val recoverable: Boolean = false) : Exception(message) {
data class NetworkError(val statusCode: Int) : AppError("Network error: $statusCode", recoverable = true)
data class ParseError(val details: String) : AppError("Failed to parse: $details", recoverable = false)
data class AuthError(val reason: String) : AppError("Auth failed: $reason", recoverable = true)
}
fun recover(error: AppError) {
when (error) {
is AppError.NetworkError -> {
if (error.recoverable) retryNetworkCall()
else logAndDismiss(error)
}
is AppError.ParseError -> logAndDismiss(error)
is AppError.AuthError -> reauthorize()
}
}
The base class provides shared behavior (the recoverable flag and Exception contract); subclasses specialize. Different error types need different recovery strategies but share logging and base behavior.
Linking to Related Kotlin Concepts
Sealed types are part of Kotlin’s larger philosophy of making invalid states unrepresentable. Once you master sealed interfaces and classes, explore value classes to create zero-overhead type-safe wrappers — they pair well with sealed types for domain modeling. For handling results elegantly, see runCatching and Result, which shows how Kotlin’s built-in Result (an inline class) works and how to build your own sealed alternatives. For understanding how the compiler verifies exhaustive when expressions on sealed types, see Kotlin contracts.
External Resources
The authoritative source is the official Kotlin documentation on sealed classes and interfaces. For performance characteristics and bytecode differences, the Kotlin GitHub repository contains compiler discussions. For seeing sealed types in action across Android frameworks, check Android’s developer documentation, which recommends sealed interfaces in modern architecture samples.
Summary
Sealed classes and sealed interfaces solve different inheritance problems. Use sealed classes when you have shared state and behavior; use sealed interfaces when defining a contract that multiple independent types should implement. Modern Kotlin libraries increasingly favor sealed interfaces for their flexibility and multi-hierarchy support. Master this distinction and you’ll write more flexible APIs, avoid inheritance antipatterns, and let your type system catch more mistakes at compile time.
This post was written by a human with the help of Claude, an AI assistant by Anthropic.
