Why Sealed Classes Are Not Enough
Sealed Interface Fundamentals
// Single-dimension sealed class (traditional)
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val code: Int, val message: String) : NetworkResult<Nothing>()
data object Loading : NetworkResult<Nothing>()
}
// Multi-dimension sealed interfaces (modern)
sealed interface Loadable {
data object Idle : Loadable
data object Loading : Loadable
data class Ready<T>(val data: T) : Loadable
data class Failed(val error: Throwable) : Loadable
}
sealed interface Refreshable {
val isRefreshing: Boolean
}
sealed interface Paginated {
val currentPage: Int
val hasNextPage: Boolean
}
// A single type can implement ALL three traits
data class ProductListState(
val products: List<Product>,
override val isRefreshing: Boolean = false,
override val currentPage: Int = 1,
override val hasNextPage: Boolean = false,
) : Loadable.Ready<List<Product>>(products),
Refreshable,
PaginatedModeling UI State with Multiple Dimensions
sealed interface ContentState {
data object Empty : ContentState
data class Populated(val items: List<Product>) : ContentState
data class Error(val message: String) : ContentState
}
sealed interface SyncState {
data object Synced : SyncState
data object Syncing : SyncState
data class Conflict(val localVersion: Int, val remoteVersion: Int) : SyncState
}
sealed interface ConnectivityState {
data object Online : ConnectivityState
data object Offline : ConnectivityState
data object Metered : ConnectivityState
}
// Compose the screen state from independent dimensions
data class ProductScreenState(
val content: ContentState,
val sync: SyncState,
val connectivity: ConnectivityState,
val selectedFilters: Set<Filter> = emptySet(),
val sortOrder: SortOrder = SortOrder.RELEVANCE
)
// Exhaustive handling in the UI layer
@Composable
fun ProductScreen(state: ProductScreenState) {
// Connectivity banner -- compiler ensures all states handled
when (state.connectivity) {
ConnectivityState.Online -> { /* no banner */ }
ConnectivityState.Offline -> OfflineBanner()
ConnectivityState.Metered -> MeteredWarningBanner()
}
// Content area -- compiler ensures all states handled
when (state.content) {
ContentState.Empty -> EmptyState()
is ContentState.Populated -> ProductGrid(state.content.items)
is ContentState.Error -> ErrorState(state.content.message)
}
}Compiler-Enforced Business Rules
sealed interface PaymentMethod {
val displayName: String
}
sealed interface CardPayment : PaymentMethod {
val last4: String
val expiryMonth: Int
val expiryYear: Int
}
sealed interface BankPayment : PaymentMethod {
val routingNumber: String
val accountLast4: String
}
data class Visa(
override val last4: String,
override val expiryMonth: Int,
override val expiryYear: Int,
val cvv: String
) : CardPayment {
override val displayName = "Visa ****$last4"
}
data class ACHTransfer(
override val routingNumber: String,
override val accountLast4: String,
val accountType: AccountType
) : BankPayment {
override val displayName = "Bank ****$accountLast4"
}
// Process function is type-safe:
// you CANNOT accidentally pass bank details to card processing
fun processCardPayment(card: CardPayment): PaymentResult {
// card.last4, card.expiryMonth etc. are guaranteed to exist
return gateway.chargeCard(card)
}
fun processBankPayment(bank: BankPayment): PaymentResult {
// bank.routingNumber, bank.accountLast4 guaranteed to exist
return gateway.debitAccount(bank)
}Event Systems with Sealed Interface Hierarchies
sealed interface UiEvent
sealed interface Trackable : UiEvent {
val eventName: String
val properties: Map<String, Any>
}
sealed interface Undoable : UiEvent {
fun undo(): UiEvent
}
sealed interface RequiresAuth : UiEvent
// An event can be trackable AND undoable
data class AddToCart(
val productId: String,
val quantity: Int
) : Trackable, Undoable, RequiresAuth {
override val eventName = "add_to_cart"
override val properties = mapOf(
"product_id" to productId,
"quantity" to quantity
)
override fun undo() = RemoveFromCart(productId, quantity)
}
data class RemoveFromCart(
val productId: String,
val quantity: Int
) : Trackable, Undoable {
override val eventName = "remove_from_cart"
override val properties = mapOf(
"product_id" to productId,
"quantity" to quantity
)
override fun undo() = AddToCart(productId, quantity)
}
// Processing pipelines handle each trait independently
class EventProcessor(
private val analytics: Analytics,
private val undoManager: UndoManager,
private val authGuard: AuthGuard
) {
suspend fun process(event: UiEvent) {
if (event is RequiresAuth) authGuard.ensureAuthenticated()
if (event is Trackable) analytics.track(event.eventName, event.properties)
if (event is Undoable) undoManager.push(event)
}
}Performance Considerations and Best Practices
Key Takeaways
- 1Sealed interfaces remove the single-inheritance constraint of sealed classes while preserving exhaustive when matching.
- 2Model independent state dimensions (loading, connectivity, sync) as separate sealed interfaces and compose them in a data class.
- 3Encode business rules in the type system to make illegal states unrepresentable at compile time.
- 4Build event systems with cross-cutting traits like Trackable, Undoable, and RequiresAuth on the same event type.
- 5Sealed interfaces have zero runtime overhead -- exhaustiveness checking is purely compile-time.
- 6Always use explicit when branches instead of else to maintain compiler safety when adding new subtypes.
Frequently Asked
When should I use sealed interfaces over sealed classes?
Use sealed interfaces when types need multiple traits or when you need to combine hierarchies. Use sealed classes for simple, single-inheritance hierarchies.
Do sealed interfaces work with serialization?
Yes, with kotlinx.serialization. Use @Serializable on the interface and implementors. Use polymorphic serialization for runtime type preservation.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.