Sealed Classes and Interfaces

A sealed class or sealed interface defines a restricted hierarchy where every possible subtype is known to the compiler at compile time. Unlike an ordinary open class, which literally anyone anywhere could subclass, a sealed type’s direct subclasses must live in the same module as the sealed declaration itself. That closed-world guarantee lets the compiler check that a when expression over the sealed type handles every case — no stray else branch required, and no forgotten case can slip through unnoticed. Sealed types are Kotlin’s answer to modeling a fixed set of possible states or shapes, such as a network response that is either loading, a success, or a failure, without resorting to brittle, hand-written type checks.

Overview / How it works

Think of a sealed class as an abstract class with one extra compiler-enforced rule: it keeps a complete list of every class that is allowed to extend it. When you write sealed class Shape, the compiler records each subclass (Circle, Rectangle, and so on) as it processes your module. Later, when it sees a when expression whose subject has type Shape and whose result is actually used (assigned to a variable, returned, or passed as an argument), it cross-checks the branches you wrote against that recorded list. If a subtype is missing and there is no else, compilation fails with "’when’ expression must be exhaustive". This is fundamentally different from a plain open class hierarchy: the compiler has no way to enumerate all possible subclasses of an open class (a consumer of your library could add more at any time), so it can never guarantee exhaustiveness and always requires an else.

Sealed classes are enforced at the declaration level, not just syntactically: every direct subclass of a sealed class or interface must be declared in the same module as the sealed type (prior to Kotlin 1.5 the rule was even stricter — the same file). This restriction is what makes the closed-world guarantee sound; if subclasses could appear in unrelated modules, the compiler could never be sure it had seen them all. Subclasses themselves can be an ordinary class, a data class (when each case needs to carry its own fields), or an object (when a case is a stateless singleton, like Loading).

Sealed class vs. sealed interface vs. enum

A sealed class can declare shared constructor parameters, properties, and methods that every subtype inherits — useful when the cases genuinely share state. A sealed interface (added in Kotlin 1.5) has no constructor and cannot hold backing-field state directly, but it is more flexible: because a Kotlin class can implement multiple interfaces but extend only one class, a sealed interface lets a single class participate in more than one closed hierarchy at once. Reach for enum class instead of either one when your cases are a truly fixed set of singleton constants that never need per-case data — enums are simpler and come with .entries, ordinal, and valueOf for free.

Syntax

sealed class ClassName {
    // properties/methods shared by every subtype go here
}

class SubclassA : ClassName()
data class SubclassB(val field: Type) : ClassName()
object SubclassC : ClassName()

sealed interface InterfaceName

class ImplementorA : InterfaceName
object ImplementorB : InterfaceName
Part Meaning
sealed Marks the class/interface as having a closed, compiler-known set of direct subtypes.
class SubclassA : ClassName() An ordinary subclass; the empty parentheses call the sealed class’s (possibly no-arg) constructor.
data class SubclassB(...) A subclass that carries its own fields and gets free equals/hashCode/toString/copy.
object SubclassC A stateless singleton case, such as Loading or Empty.
Same module rule All direct subtypes must be declared in the same module as the sealed declaration.

Examples

Example 1: exhaustive area calculation over a shape hierarchy

sealed class Shape

data class Circle(val radius: Double) : Shape()
data class Rectangle(val width: Double, val height: Double) : Shape()
data class Triangle(val base: Double, val height: Double) : Shape()

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

fun describe(shape: Shape): String = when (shape) {
    is Circle -> "Circle"
    is Rectangle -> "Rectangle"
    is Triangle -> "Triangle"
}

fun main() {
    val shapes = listOf(
        Circle(radius = 2.0),
        Rectangle(width = 3.0, height = 4.0),
        Triangle(base = 5.0, height = 6.0)
    )
    for (shape in shapes) {
        println("${describe(shape)} area = ${"%.2f".format(area(shape))}")
    }
}

Output:

Circle area = 12.57
Rectangle area = 12.00
Triangle area = 15.00

Because Shape is sealed, the compiler knows Circle, Rectangle, and Triangle are the only possible subtypes, so area and describe compile without an else branch. Each branch smart-casts shape to the matched subtype, giving direct access to radius, width/height, or base/height.

Example 2: modeling an API response with a sealed interface

sealed interface ApiResult

data class Success(val data: T) : ApiResult
data class Failure(val message: String) : ApiResult
object Loading : ApiResult

fun  render(result: ApiResult): String = when (result) {
    is Success -> "Loaded: ${result.data}"
    is Failure -> "Error: ${result.message}"
    Loading -> "Loading..."
}

fun main() {
    val results: List> = listOf(
        Loading,
        Success(42),
        Failure("timeout")
    )
    for (result in results) {
        println(render(result))
    }
}

Output:

Loading...
Loaded: 42
Error: timeout

Here ApiResult<out T> is a sealed interface rather than a class because none of its cases need a shared constructor — Loading is a singleton, and Success/Failure each carry completely different data. The out T variance lets Failure and Loading (both ApiResult<Nothing>) sit in a List<ApiResult<Int>> alongside Success(42), because Nothing is a subtype of every type. The when in render is exhaustive over all three known implementors with no else.

Example 3: a recursive expression tree (classic sealed-class use case)

sealed class Expr

data class Num(val value: Double) : Expr()
data class Add(val left: Expr, val right: Expr) : Expr()
data class Mul(val left: Expr, val right: Expr) : Expr()

fun eval(expr: Expr): Double = when (expr) {
    is Num -> expr.value
    is Add -> eval(expr.left) + eval(expr.right)
    is Mul -> eval(expr.left) * eval(expr.right)
}

fun main() {
    // Represents (2 + 3) * 4
    val expression: Expr = Mul(Add(Num(2.0), Num(3.0)), Num(4.0))
    println("Result = ${eval(expression)}")
}

Output:

Result = 20.0

Sealed classes shine for recursive, tree-shaped data like this expression AST. eval calls itself on expr.left and expr.right, and because Expr is sealed, the compiler guarantees every node type — including any added later — is handled somewhere in the recursion.

How it works step by step

Tracing Example 3’s evaluation: main builds expression = Mul(Add(Num(2.0), Num(3.0)), Num(4.0)). Calling eval(expression) matches the is Mul branch, smart-casting expr to Mul and evaluating expr.left first: eval(Add(Num(2.0), Num(3.0))) matches is Add, which evaluates eval(Num(2.0)) (returns 2.0 via the is Num branch) and eval(Num(3.0)) (returns 3.0), summing to 5.0. Back in the outer Mul branch, expr.right evaluates to eval(Num(4.0)) = 4.0. The two results multiply to 20.0, which main prints.

The compile-time side is just as important as the runtime trace: while compiling eval, the compiler looks up every direct subclass registered under sealed class ExprNum, Add, Mul — and confirms the when covers all three before it will even emit bytecode. If you later add a fourth subtype, say data class Sub(val left: Expr, val right: Expr) : Expr(), this exact eval function stops compiling until you add an is Sub -> branch. A plain if/else if chain using instanceof-style checks would instead compile silently and simply mishandle Sub at runtime — this is the core safety guarantee sealed hierarchies buy you.

Common Mistakes

Mistake 1: relying on statement-form when, which isn’t checked for exhaustiveness

Exhaustiveness is only enforced when a when‘s result is actually used as a value. A when used purely as a statement (its result discarded) compiles even if it misses a case — the missing branch just does nothing at runtime, with no warning.

sealed class Notification
data class Email(val subject: String) : Notification()
data class Sms(val text: String) : Notification()
data class Push(val title: String) : Notification()

fun handleWrong(notification: Notification) {
    // Even in statement form, the K2 compiler requires every sealed subtype to be
    // handled -- this is a COMPILE ERROR, not a silent bug, in modern Kotlin.
    when (notification) {
        is Email -> println("Email: ${notification.subject}")
        is Sms -> println("SMS: ${notification.text}")
    }
}

fun main() {
    handleWrong(Push("You have a new follower"))
    println("Done")
}

Output:

error: 'when' expression must be exhaustive. Add the 'is Push' branch or an 'else' branch.

This is actually good news: in older Kotlin (the pre-2.0 K1 compiler), a non-exhaustive statement-form when over a sealed type only produced a warning, so the missing Push branch really did compile silently and swallow the case. The modern K2 compiler (Kotlin 2.x, used throughout this course) closes that gap and promotes it to a hard compile error — you get the same safety net whether when is used as a statement or an expression. It’s still worth writing dispatch logic as an expression-form when where you can, since that makes the exhaustiveness requirement obvious from the return type rather than relying on the sealed-type special case:

sealed class Notification
data class Email(val subject: String) : Notification()
data class Sms(val text: String) : Notification()
data class Push(val title: String) : Notification()

fun describe(notification: Notification): String = when (notification) {
    is Email -> "Email: ${notification.subject}"
    is Sms -> "SMS: ${notification.text}"
    is Push -> "Push: ${notification.title}"
}

fun main() {
    println(describe(Push("You have a new follower")))
    println("Done")
}

Output:

Push: You have a new follower
Done

Because describe returns a String that is actually used, deleting the is Push branch here would fail to compile — exactly the safety net you want.

Mistake 2: adding a redundant else that swallows future cases

Using the ApiResult hierarchy from Example 2, adding an else branch compiles, but it throws away the whole benefit of sealing the type: a new subtype added six months from now will silently fall into else instead of causing a compile error that reminds you to handle it.

// Wrong: the else swallows any ApiResult subtype added in the future.
fun renderWrong(result: ApiResult): String = when (result) {
    is Success -> "Loaded: ${result.data}"
    else -> "Unknown"
}
// Right: every known case is handled explicitly, no else needed.
fun renderRight(result: ApiResult): String = when (result) {
    is Success -> "Loaded: ${result.data}"
    is Failure -> "Error: ${result.message}"
    Loading -> "Loading..."
}

Only reach for else on a sealed type when you have a deliberate, documented reason to ignore future cases — treat every one as a small hole punched in your compile-time safety net.

Mistake 3: declaring a subclass outside the sealed type’s module

Sealed types enforce their closed-world guarantee at compile time: a subclass declared outside the same module simply fails to compile.

// File: Shapes.kt (module: app)
sealed class Shape

// File in a separate module that depends on "app":
class Hexagon : Shape()
// Compiler error: this type is sealed, and its implementations
// should be declared in the same module. 'Shape' is not accessible
// from this module, so Hexagon cannot extend it here.

If you find yourself wanting to add subclasses from an unrelated module, that hierarchy should probably be an ordinary open class instead — sealing it is a promise that you, the author, know every possible case.

Best Practices

  • Use sealed interface instead of sealed class when subtypes don’t need shared constructor state — it also lets one class implement more than one sealed hierarchy at once.
  • Prefer expression-form when (assign or return its result) over statement-form when for sealed-type dispatch, so the compiler actually enforces exhaustiveness.
  • Avoid adding a catch-all else to a when over a sealed type unless you deliberately want to ignore future cases.
  • Model each subtype as a data class (when it carries fields) or an object (when it’s a stateless singleton) to get free equals/hashCode/toString/copy.
  • Reach for enum class instead of a sealed class made only of object subtypes — it’s simpler and gives you .entries, ordinal, and valueOf for free.
  • Keep every subclass in the same module (often the same file, or nested inside the sealed declaration) so the closed set stays obvious to readers.

Practice Exercises

  • Model a sealed class TrafficLight with singleton subtypes Red, Yellow, and Green. Write fun nextLight(current: TrafficLight): TrafficLight that returns the next light in the cycle Red → Green → Yellow → Red using an exhaustive when expression. Hint: starting from Red, four successive calls should yield Green, Yellow, Red, Green.
  • Extend the Shape hierarchy from Example 1 with a new subtype Square(val side: Double). Update area and describe. What message does the compiler give you if you forget to add a branch for Square in one of them?
  • Build a sealed class JsonValue with subtypes JsonString(val value: String), JsonNumber(val value: Double), JsonBoolean(val value: Boolean), and a singleton JsonNull. Write fun stringify(value: JsonValue): String that renders each case to JSON text (e.g. JsonString("hi") becomes "hi" with quotes, JsonNull becomes null). Expected output for stringify(JsonBoolean(true)) is true.

Summary

  • A sealed class or interface declares a closed set of subtypes that are all known to the compiler at compile time.
  • Direct subclasses of a sealed type must be declared in the same module, which is what makes the closed-world guarantee sound.
  • Expression-form when over a sealed type is checked for exhaustiveness — every subtype must be handled unless you deliberately add else.
  • Statement-form when is not checked for exhaustiveness, which can silently skip a case — prefer expression form for dispatch logic.
  • sealed class supports shared constructor state; sealed interface is lighter and lets a class implement multiple sealed hierarchies.
  • Model subtypes as data class or object, avoid unnecessary else branches, and use enum class instead when no case needs its own data.