Sealed Classes vs. Traditional Inheritance in Kotlin: When to Choose Which
Learn how to decide between sealed classes and classic inheritance in Kotlin. Compare constraints, trade‑offs, and see a hands‑on example with compile‑time exhaustiveness checks.
12 Oct 2025, 19:27 UTC

Why the Decision Matters
When modeling a finite set of domain types—such as UI states, network responses, or command variants—Kotlin offers two main patterns: sealed classes and classic inheritance (abstract classes or interfaces). The choice determines how safe the code is against missing branches, how modular the hierarchy can be, and whether future extensions can be added without breaking the compiler.
Decision Guide: State the Decision & Constraints
Choose sealed classes when:
- The set of variants is stable and will not grow beyond the file that declares them.
- Compile‑time exhaustiveness in
whenexpressions is desired to avoid runtime surprises. - The hierarchy is small to medium and can be kept in a single module.
Choose traditional inheritance when:
- Variants may be added by other modules or third‑party libraries.
- The type must be used as a generic parameter that expects extensibility.
- You need to expose a public API that can evolve independently of the core module.
Compact Comparison Table
| Feature | Sealed Class | Traditional Inheritance |
|---|---|---|
Exhaustiveness in when | Compile‑time check, no else needed | No check; else required or risk of unhandled type |
| Modularity / Extensibility | Limited to file; cannot add outside | Open for extension across modules |
| Generic Compatibility | Cannot be used as a parameter for open generics | Fully compatible |
| Performance | Comparable; compiler emits synthetic enum for dispatch | Standard instanceof checks |
| Readability in Large Hierarchies | May become cluttered if many subclasses in one file | Better separation across files |
Trade‑Offs Explained
- Compile‑time safety vs. flexibility: Sealed classes lock the hierarchy, giving you a guaranteed exhaustive
whenbut preventing external extensions. Inheritance gives you freedom but at the cost of potential runtime errors if a new subclass is forgotten. - Modularity: Sealed classes are confined to a single file, which can simplify small modules but makes cross‑module evolution cumbersome. Traditional inheritance scales better across multiple modules.
- Generic patterns: Many design patterns (e.g., factory, repository) rely on open classes. Sealed classes break these patterns because they cannot be extended by the framework or client code.
- Maintenance overhead: Adding a new variant in a sealed class requires recompiling the entire module. With inheritance, only the module that introduces the new subclass needs to be compiled.
Concrete Implementation & Validation
1. Define a Sealed Class Hierarchy
// File: Result.kt
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val code: Int) : Result()
object Pending : Result()
}
All variants are in the same file, ensuring the hierarchy is closed.
2. Use a when Expression with Exhaustiveness
fun handle(result: Result) {
when (result) {
is Result.Success -> println("Success: ${'$'}{result.data}")
is Result.Error -> println("Error code: ${'$'}{result.code}")
is Result.Pending -> println("Still pending…")
}
}
Because Result is sealed, the compiler will report an error if a new subclass is added and the when is not updated.
3. Compile and Verify Exhaustiveness
Run the Kotlin compiler on the module:
# Compile the module
kotlinc -d build/classes src/main/kotlin/**/*.kt
If a new subclass is added (e.g., object Unknown : Result()) and handle is not updated, the compiler will emit:
error: exhaustive 'when' expression must cover all possible cases in sealed class Result
4. Unit Test All Branches
class ResultHandlerTest {
@Test
fun testAllBranches() {
val outputs = mutableListOf()
fun capture(result: Result) {
val buffer = StringWriter()
System.setOut(PrintStream(buffer))
handle(result)
outputs.add(buffer.toString().trim())
}
capture(Result.Success("data"))
capture(Result.Error(404))
capture(Result.Pending)
assertEquals(listOf("Success: data", "Error code: 404", "Still pending…"), outputs)
}
}
Running ./gradlew test should pass, confirming that each branch executes without throwing.
5. Static Analysis with Detekt
# Run Detekt on the source set
./gradlew detekt
Detekt will flag any when on a sealed class that misses a branch. A clean run indicates no hidden gaps.
6. Inspect Generated Bytecode
# Use javap to view the sealed class structure
javap -classpath build/classes -verbose Result
You should see a synthetic enum reference used for dispatch, confirming efficient runtime checks.
Limitations & Practical Checks
- File‑level restriction: If you need to add a new variant in a different module, the sealed class approach will break. Keep a note that any new module must recompile the module declaring the sealed class.
- Generic incompatibility: Sealed classes cannot be used as type parameters for open generic APIs (e.g.,
Repository<T>whereTis expected to be open). In such cases, prefer an interface. - Use the
whenexhaustiveness check, detekt, and unit tests as a safety net. If any of these fail, review whether the sealed class should be replaced with an interface or abstract class.
When to Re‑evaluate
Consider switching to traditional inheritance if:
- The domain model is expected to grow with new variants from external contributors.
- You need to expose the type hierarchy as a public API that other modules can extend.
- Generic patterns requiring open types are central to your architecture.
Otherwise, sealed classes provide a clean, compile‑time safe way to model a closed set of types with minimal boilerplate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.