Skip to content
AgileLogics
6 min read

Sealed Classes and Interfaces

Prerequisites: Enum classes, Data classes

Enums work when every option is a single value. When each option needs to carry different data, use a sealed class or interface: a closed family of types that the compiler knows completely.

Example: modeling results

sealed interface PaymentResult {
    data class Success(val transactionId: String) : PaymentResult
    data class Declined(val reason: String) : PaymentResult
    data object Pending : PaymentResult
}

fun message(result: PaymentResult): String = when (result) {
    is PaymentResult.Success -> "Paid (ref ${result.transactionId})"
    is PaymentResult.Declined -> "Declined: ${result.reason}"
    PaymentResult.Pending -> "Waiting for the bank..."
}

fun main() {
    println(message(PaymentResult.Success("TX-1042")))
    println(message(PaymentResult.Declined("insufficient funds")))
    println(message(PaymentResult.Pending))
}

Expected output

Paid (ref TX-1042)
Declined: insufficient funds
Waiting for the bank...
  • All subtypes of a sealed type must be declared in the same package and module, so the compiler knows the complete list.
  • That makes when exhaustive with no else. Add a new subtype, and every when that doesn't handle it becomes a compile error — exactly where your attention is needed.
  • is PaymentResult.Success smart-casts result, so result.transactionId works in that branch.

Making impossible states impossible

Compare a screen state modeled with separate nullable fields — where data and error could accidentally both be set — with a sealed type:

sealed interface ScreenState {
    data object Loading : ScreenState
    data class Loaded(val items: List<String>) : ScreenState
    data class Failed(val message: String) : ScreenState
}

fun main() {
    val state: ScreenState = ScreenState.Loaded(listOf("Ada", "Linus"))

    if (state is ScreenState.Loaded) {
        println("Showing ${state.items.size} items")
    }
    println(state)
}
Showing 2 items
Loaded(items=[Ada, Linus])

This pattern is everywhere in Android apps, where a screen's state is exposed as exactly one of these cases.

Sealed class or sealed interface?

Prefer a sealed interface. Use a sealed class when the subtypes need to share state or constructor parameters.

Practice

Define sealed interface Shape with Circle(radius: Double) and Rectangle(width: Double, height: Double), and an area function using when. Print both areas with two decimal places for a circle of radius 1 and a 2 by 3 rectangle.

Show solution
sealed interface Shape {
    data class Circle(val radius: Double) : Shape
    data class Rectangle(val width: Double, val height: Double) : Shape
}

fun area(shape: Shape): Double = when (shape) {
    is Shape.Circle -> Math.PI * shape.radius * shape.radius
    is Shape.Rectangle -> shape.width * shape.height
}

fun main() {
    println("%.2f".format(area(Shape.Circle(1.0))))
    println("%.2f".format(area(Shape.Rectangle(2.0, 3.0))))
}
3.14
6.00