SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:42
UP00:01
MOD · ARTICLE · KOTLINS/N · AX-KOTLINPUBLISHED
All Articles
Kotlin · Updated 10 MIN

Kotlin Sealed Interfaces: Modeling Complex Domain State Without Compromise

Go beyond sealed classes: sealed interfaces for exhaustive type hierarchies, multi-trait state models, and compiler-enforced business rules in Android apps.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Why Sealed Classes Are Not Enough

Sealed classes are how most Kotlin code models state: a closed set of subtypes and a when that has to deal with all of them. Their limit is the limit of any class. A subtype extends one parent. Real state has more than one dimension. A response can be cacheable and paginated. A UI event can be trackable and undoable. With one sealed class you either write a subtype for every combination or you give up the type safety and cast. Sealed interfaces arrived in Kotlin 1.5.0, released on 5 May 2021, and the release notes state both halves of the idea. First, "Kotlin interfaces can now have the sealed modifier, which works on interfaces in the same way it works on classes: all implementations of a sealed interface are known at compile time." Second, the part that matters here: "sealed interfaces enable more flexible restricted class hierarchies because a class can directly inherit more than one sealed interface" (What's new in 1.5.0). One correction before the code. The first sample in an earlier version of this article declared a data class that extended another data class. A data class is final, so that sample did not compile. It has been replaced.

What does sealed actually guarantee?

The guarantee is about where implementations may live. "All direct subclasses of a sealed class are known at compile time. No other subclasses may appear outside the module and package within which the sealed class is defined." The same holds for interfaces: "once a module with a sealed interface is compiled, no new implementations can be created." Note that it is module and package, both. This article used to describe it as one or the other depending on the Kotlin version. The rule as documented: "Direct subclasses of sealed classes and interfaces must be declared in the same package." Two details are easy to miss: - The guarantee covers direct subtypes only. "These restrictions don't apply to indirect subclasses." If an implementation of your sealed interface is an open class, anyone can extend that class. Keep the leaves final, which data classes and objects are. - Enums can join in. "enum classes can't extend a sealed class, or any other class. However, they can implement sealed interfaces". That is useful when one dimension of your state is a plain list of constants.
kotlin
// One dimension: a sealed class is enough
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>()
}

// Several dimensions: one sealed interface per trait
sealed interface ScreenState

sealed interface Refreshable {
    val isRefreshing: Boolean
}

sealed interface Paginated {
    val currentPage: Int
    val hasNextPage: Boolean
}

data object Loading : ScreenState

data class Failed(val message: String) : ScreenState

// A single type implements all three. A class could not do this
// by extending three sealed classes.
data class ProductList(
    val products: List<Product>,
    override val isRefreshing: Boolean = false,
    override val currentPage: Int = 1,
    override val hasNextPage: Boolean = false,
) : ScreenState, Refreshable, Paginated

// An enum can implement a sealed interface too
sealed interface SortOrder
enum class ProductSort : SortOrder { RELEVANCE, PRICE, NEWEST }

Modeling UI State with Multiple Dimensions

Take a product listing. Its content, its sync status and its connectivity change independently. Flatten them into one hierarchy and you get names like LoadingWithFiltersOffline. Model each as its own sealed interface and compose them in one data class. The compiler then checks each dimension separately, with one condition this article used to skip. Exhaustiveness is a rule about when used as an expression. The documentation draws the line in two sentences: "If you use when as a statement, you don't need to cover all possible cases" and "If you use when as an expression, you must cover all possible cases." The old sample used bare when statements inside a composable and commented that the compiler ensured every state was handled. Write the mapping as an expression and the rule you are relying on is the documented one. For a sealed subject no else is needed: "you don't need to add an else clause". That is the property to protect, because an else branch is exactly what hides the new subtype you add next quarter. This is the same shape the MVVM vs MVI comparison arrives at for screen state.
kotlin
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
}

data class ProductScreenState(
    val content: ContentState,
    val sync: SyncState,
    val connectivity: ConnectivityState,
)

// when as an EXPRESSION: every case must be covered, no else
fun ConnectivityState.bannerText(): String? = when (this) {
    ConnectivityState.Online -> null
    ConnectivityState.Offline -> "You are offline"
    ConnectivityState.Metered -> "Using mobile data"
}

fun SyncState.label(): String = when (this) {
    SyncState.Synced -> "Up to date"
    SyncState.Syncing -> "Syncing"
    is SyncState.Conflict -> "Conflict: local v$localVersion, remote v$remoteVersion"
}

@Composable
fun ProductScreen(state: ProductScreenState) {
    state.connectivity.bannerText()?.let { Banner(it) }
    SyncLabel(state.sync.label())
}

Compiler-Enforced Business Rules

The second use is narrowing what a function accepts. A check with require() fails when the code runs. A parameter type fails when the code compiles. Payments are the standard example. A card and a bank transfer carry different data, and the functions that process them should not be able to receive the wrong one. Give each family a sealed interface under a common parent and the signature does the validation.
kotlin
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,
) : CardPayment {
    override val displayName = "Visa ****$last4"
}

data class AchTransfer(
    override val routingNumber: String,
    override val accountLast4: String,
) : BankPayment {
    override val displayName = "Bank ****$accountLast4"
}

// A BankPayment cannot be passed here. The mistake does not compile.
fun processCardPayment(card: CardPayment): PaymentResult =
    gateway.chargeCard(card)

fun processBankPayment(bank: BankPayment): PaymentResult =
    gateway.debitAccount(bank)

// And the top-level dispatch is still exhaustive
fun process(method: PaymentMethod): PaymentResult = when (method) {
    is CardPayment -> processCardPayment(method)
    is BankPayment -> processBankPayment(method)
}

Event Systems with Sealed Interface Hierarchies

The same idea tags events with traits. Some events are tracked, some can be undone, some need a signed-in user, and one event can be all three. An earlier version of this section claimed the pattern removes the need for is checks, directly above a sample that used three of them. The accurate claim is smaller: the checks live in one processor, each trait is handled by one line, and an event declares its traits where it is defined.
kotlin
sealed interface UiEvent

sealed interface Trackable : UiEvent {
    val eventName: String
}

sealed interface Undoable : UiEvent {
    fun undo(): UiEvent
}

sealed interface RequiresAuth : UiEvent

// One event, three traits
data class AddToCart(
    val productId: String,
    val quantity: Int,
) : Trackable, Undoable, RequiresAuth {
    override val eventName = "add_to_cart"
    override fun undo() = RemoveFromCart(productId, quantity)
}

data class RemoveFromCart(
    val productId: String,
    val quantity: Int,
) : Trackable, Undoable {
    override val eventName = "remove_from_cart"
    override fun undo() = AddToCart(productId, quantity)
}

// The trait checks live in exactly one place
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)
        if (event is Undoable) undoManager.push(event)
    }
}

Limits and Best Practices

This section used to open with two claims about what sealed interfaces cost at run time. We have no source for either, so they are gone. What the documentation does give you is a list of limits worth knowing before you build on the feature. - Shared code has an extra rule. In multiplatform projects, "direct subclasses of sealed classes must reside in the same source set". And if the sealed class is an expected declaration in common code, "you still need an else branch", because platform code may add subclasses common code cannot see. Plan for that before moving a state model into a Kotlin Multiplatform module. - Serialization needs every subtype marked. "All subclasses of a sealed class must be explicitly marked as @Serializable" (kotlinx.serialization). The serialization guide has the full pattern. - Guard conditions exist now. A branch can carry a second condition after if, and "You can use guard conditions with when as long as it has a subject." Our own practice, labelled as such: keep leaf types final by using data classes and objects, keep hierarchies to two levels, define the interfaces in the domain layer, and never add an else to a when over a sealed type.
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Sealed interfaces arrived in Kotlin 1.5.0. A class can implement several, which a sealed class hierarchy cannot offer.
  2. 2Direct subtypes must be in the same package and module. Indirect subtypes are not restricted, so keep leaf types final.
  3. 3Exhaustiveness is documented for when used as an expression. Write state mappings as expressions, with no else branch.
  4. 4Model independent dimensions of state as separate sealed interfaces and compose them in one data class.
  5. 5Use sealed interfaces as parameter types to make an invalid call fail at compile time.
  6. 6In multiplatform code, direct subclasses must share a source set, and an expected sealed class still needs else.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

When should I use sealed interfaces over sealed classes?

Use a sealed interface when one type needs to belong to more than one closed family, such as an event that is both trackable and undoable. Kotlin lets a class implement several sealed interfaces and extend only one class. Use a sealed class when the subtypes share state or a constructor and there is one dimension.

Do sealed interfaces work with serialization?

Yes, with kotlinx.serialization. Mark the sealed parent and every subtype with @Serializable; the documentation says all subclasses must be marked explicitly. The JSON format then adds a type key as a discriminator, whose value is the fully qualified class name unless you set one with @SerialName on each subtype.

Does the compiler always check that a when over a sealed type is exhaustive?

The documented guarantee is for when used as an expression: it must cover all possible cases, and for a sealed subject it can do so without an else branch. The documentation says a when used as a statement does not need to cover every case. Write state mappings as expressions to rely on the documented rule.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

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.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA