How the Kotlin Compiler Knows You Covered Every Sealed Subclass in `when`

skydovesJaewoong Eum (skydoves)||11 min read

How the Kotlin Compiler Knows You Covered Every Sealed Subclass in when

You write a when expression on a sealed class, cover every subclass, and the compiler lets you skip the else branch. Then a teammate adds a new subclass in another file, and every when in the project turns red with "when expression must be exhaustive." The compiler caught the missing case at compile time, before any test could fail. But sealed subclasses can live in different files across the module. How does the compiler know them all, and how does it verify that your when branches cover every one?

In this article, you'll trace through the compiler's sealed subclass collection phase, the exhaustiveness checking algorithm that compares your when branches against the full subclass set, the special handling for enums and booleans and nullable types, and how the final bytecode represents a sealed when at runtime.

The fundamental problem: Subclasses are scattered across the module

With enums, exhaustiveness is simple. An enum class declares all its entries in one place, and the compiler can read them directly from the declaration. Sealed classes are different. Their subclasses can be declared in separate files, as long as they're in the same module:

// Shape.kt
sealed interface Shape

// Circle.kt
data class Circle(val radius: Double) : Shape

// Rectangle.kt
data class Rectangle(val width: Double, val height: Double) : Shape

// Triangle.kt
data class Triangle(val base: Double, val height: Double) : Shape

When you write when (shape) { is Circle -> ... is Rectangle -> ... }, the compiler needs to know that Triangle exists in a different file and that you missed it. This requires a global collection pass before any exhaustiveness check can happen.

This is also why sealed classes have the "same module" restriction. If subclasses could be declared in a different module (a different Gradle dependency), the compiler compiling your module wouldn't see them. The exhaustiveness guarantee would break. The restriction isn't arbitrary: it exists specifically so that the compiler can build a complete subclass list from the files it has access to during compilation.

Phase 1: Collecting sealed inheritors

Before the compiler can check any when expression, it needs a complete list of every sealed class's direct subclasses. This happens in a dedicated resolve phase called FirSealedClassInheritorsProcessor.

The processor walks every file in the module and looks at every class declaration's supertype list. If a class inherits from a sealed class or sealed interface, it records the inheritor. Looking at the core logic:

override fun visitRegularClass(regularClass: FirRegularClass, data: Any?) {
    super.visitRegularClass(regularClass, data)

    for (parent in regularClass.superTypeRefs) {
        val parentClassId = parent.resolvedClassId ?: continue
        val parentClass = /* look up the sealed parent */

        if (parentClass.modality != Modality.SEALED) continue
        if (parent.classId.packageFqName != regularClass.classId.packageFqName)
            continue

        sealedClassInheritors
            .getOrPut(parentClassId) { mutableSetOf() }
            .add(regularClass.classId)
    }
}

The package check on line 6 enforces the sealed contract: inheritors of a sealed interface must be in the same package. For sealed classes in Kotlin 1.5 and later, the constraint is the same module rather than the same file, but the package must still match.

After this pass completes, every sealed class in the module has a sealedInheritorsAttr property containing the sorted list of its direct subclass ClassId values. This list is attached to the FirRegularClass and available to any subsequent analysis phase.

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor