If you’ve ever dealt with algebraic data types in other languages or wanted to model closed hierarchies without the fear of missing cases, this is for you. We’ll cover everything from sealed classes versus interfaces to designing closed-world models, with full code examples to illustrate each point. By the end, you’ll have a toolkit for writing more maintainable, compiler-verified code.
Let’s jump in.
Sealed Classes vs Sealed Interfaces
Sealed types in Kotlin are a powerful way to represent restricted hierarchies. They’re like enums on steroids, allowing you to define a fixed set of subtypes that the compiler knows about exhaustively. This is crucial for modeling sum types (e.g., “this or that, but nothing else”) in a type-safe manner.
First, sealed classes. Introduced in Kotlin 1.0, a sealed class is abstract and can’t be instantiated directly. Its subclasses must be defined in the same file (or, since Kotlin 1.5, in the same package and module). This restriction ensures the compiler can verify all possible subtypes at compile time.
Here’s a classic example: modeling API responses.
Kotlin
sealed class ApiResponse
data class Success(val data: String) : ApiResponse()
data class Error(val message: String) : ApiResponse()
class Loading : ApiResponse() // Non-data subclass for state
// Usage
fun handleResponse(response: ApiResponse) {
when (response) {
is Success -> println("Data: ${response.data}")
is Error -> println("Error: ${response.message}")
is Loading -> println("Loading...")
}
}
In this setup, the when expression is exhaustive—no else needed because the compiler knows these are the only possibilities. If you add a new subclass like NetworkError, the compiler will flag all unhandled when branches, preventing runtime surprises.
Now, sealed interfaces (Kotlin 1.5+). They work similarly but allow for more flexibility. Interfaces don’t require constructors, and subtypes can be classes, objects, or even other interfaces. Importantly, sealed interfaces support multiple inheritance in hierarchies, which sealed classes don’t (since classes are single-inheritance).
Consider modeling shapes in a graphics app:
Kotlin
sealed interface Shape
class Circle(val radius: Double) : Shape
class Rectangle(val width: Double, val height: Double) : Shape
object Empty : Shape // Singleton for no shape
// Usage with multiple inheritance if needed
interface Drawable
class Square(val side: Double) : Shape, Drawable // Possible with sealed interface
fun draw(shape: Shape) {
when (shape) {
is Circle -> println("Drawing circle with radius ${shape.radius}")
is Rectangle -> println("Drawing rectangle ${shape.width}x${shape.height}")
is Empty -> println("Nothing to draw")
// Compiler ensures exhaustiveness
}
}
Key differences:
- Sealed Classes: Better for data-heavy hierarchies (with constructors). Subclasses must extend directly.
- Sealed Interfaces: Ideal for behavior-focused or mixed hierarchies. Allow anonymous objects and more extensibility within the sealed bounds.
As a mid-level dev, choose sealed classes for simple ADTs (Algebraic Data Types) and interfaces when you need to mix in other traits. Both enforce closed worlds, but interfaces reduce boilerplate in complex models.
Exhaustiveness Checking Rules
Exhaustiveness is Kotlin’s killer feature for sealed types and enums—it ensures every possible case is handled, catching errors at compile time rather than runtime.
The rules are straightforward but have nuances. For a when expression on a sealed type or enum, the compiler checks if all subtypes/values are covered. If not, it demands an else or flags an error if used as an expression (where when must return a value).
But it’s not just about direct subtypes. Nested sealeds complicate things. Consider a hierarchical response:
Kotlin
sealed class ApiResponse
sealed class Success : ApiResponse()
data class UserData(val name: String) : Success()
data class PostData(val content: String) : Success()
sealed class Failure : ApiResponse()
data class NetworkError(val code: Int) : Failure()
data class ServerError(val message: String) : Failure()
// Usage
fun process(response: ApiResponse): String = when (response) {
is UserData -> "User: ${response.name}"
is PostData -> "Post: ${response.content}"
is NetworkError -> "Error code: ${response.code}"
is ServerError -> "Server: ${response.message}"
// Exhaustive: All leaves covered, even though Success/Failure are intermediates
}
Here, you don’t need to handle Success or Failure directly—the compiler traverses the hierarchy and verifies leaf coverage.
Rules in detail:
- Direct Subtypes: Must cover all if no nesting.
- Nested Sealeds: Recursively checks all terminal subtypes (non-sealed leaves).
- Objects vs Classes: Objects (singletons) count as one case; data classes as types.
- Nullability: If the type is nullable (e.g., ApiResponse?), you must handle null explicitly.
- Smart Casts: In when with is, properties are smart-cast, but exhaustiveness ignores that—it’s about types.
Edge case: If a subtype is in another file (pre-1.5), it’s not sealed—exhaustiveness breaks. Always keep them co-located.
For middle devs: Always prefer exhaustive when over if-else chains for sealed types. It future-proofs your code against hierarchy changes.
when Exhaustiveness and Compiler Guarantees
Building on exhaustiveness, let’s zoom in on when expressions. In Kotlin, when can be exhaustive for booleans, enums, sealeds, and even strings/numbers in limited cases, but sealeds shine here.
Compiler guarantees: If exhaustive, no else is needed, and the expression infers a type safely. This prevents “impossible” branches at runtime.
Example with error handling:
Kotlin
sealed class Result<out T>
data class Ok<T>(val value: T) : Result<T>()
data class Err(val exception: Exception) : Result<Nothing>()
fun compute(): Result<Int> = Ok(42) // Or Err(Exception("Boom"))
fun main() {
val result = compute()
val value: Int = when (result) { // Type infers to Int, no nullability
is Ok -> result.value
is Err -> throw result.exception // Handles error path
}
println(value) // Guaranteed non-error
}
Here, the compiler guarantees that value is Int because all paths are covered, and Err throws (or could return a default, but exhaustiveness ensures coverage).
More guarantees:
- No Runtime Checks: Unlike Java’s switch, no hidden defaults.
- Refactoring Safety: Add a subtype? Compiler errors everywhere it’s unhandled.
- Expression vs Statement: As expression, must be exhaustive; as statement, optional but recommended.
Pitfall for mids: Overusing else defeats the purpose. Train yourself to remove it and let the compiler guide you.
Advanced: Combine with Elvis for nullables:
Kotlin
val nullableResponse: ApiResponse? = null
val handled = when (nullableResponse) {
is Success -> "Success"
is Error -> "Error"
null -> "No response"
// Exhaustive including null
}
This leverages Kotlin’s flow typing for safety.
(Word count so far: ~1250)
Evolution of Sealed Hierarchies
Sealed hierarchies aren’t static—they evolve. Kotlin’s design supports adding subtypes without breaking existing code, but with compiler nudges for updates.
Start simple, then expand. Suppose we have a basic UI state:
Kotlin
sealed class UiState
object Idle : UiState()
data class Loaded(val data: List<String>) : UiState()
data class Error(val msg: String) : UiState()
Later, add Loading:
Kotlin
data class Loading(val progress: Int) : UiState() // New subtype
Now, all when on UiState will error until you add the branch. This is intentional—forces updates.
Evolution tips:
- Backward Compat: Adding subtypes is safe for consumers if they use else, but for exhaustiveness, it’s a breaking change (good for internal libs).
- Versioning: In libraries, use sealed interfaces for more flexibility; subclasses can be added in submodules (Kotlin 1.5+).
- Deprecation: Deprecate old subtypes gradually.
Example evolution in a library:
Initial version:
Kotlin
// Library v1
sealed interface Event
object Click : Event
object Hover : Event
Consumer:
Kotlin
fun handle(event: Event) = when (event) {
Click -> "Clicked"
Hover -> "Hovered"
}
v2 adds Drag:
Kotlin
object Drag : Event
Consumer’s code breaks at compile time—must update. Ideal for catching oversights.
For middle devs: Design with evolution in mind. Use sealeds for internal domains, open classes for extensible APIs.
Enums: Semantics and Runtime Costs
Enums in Kotlin are classes under the hood, offering more than Java’s (e.g., properties, methods). They’re sealed implicitly, enabling exhaustiveness.
Semantics: Each enum constant is a singleton subclass. You can add state/behavior.
Basic enum:
Kotlin
enum class Color {
RED, GREEN, BLUE
}
// Exhaustive when
fun describe(color: Color) = when (color) {
RED -> "Warm"
GREEN -> "Neutral"
BLUE -> "Cool"
}
Advanced with properties:
Kotlin
enum class Planet(val mass: Double, val radius: Double) {
EARTH(5.97e24, 6371.0),
MARS(6.42e23, 3389.5);
fun surfaceGravity(): Double = 6.67430e-11 * mass / (radius * radius * 1e6)
}
fun main() {
println(Planet.EARTH.surfaceGravity()) // ~9.81
val planet: Planet = Planet.MARS
when (planet) { // Exhaustive
Planet.EARTH -> println("Home")
Planet.MARS -> println("Red planet")
}
}
Runtime costs: Enums are loaded eagerly (static initialization). For large enums, this can impact startup. Each constant is an object, so memory footprint grows linearly.
Costs:
- Memory: ~50-100 bytes per constant (plus fields).
- Performance: Enum lookups are fast (array-based), but avoid in hot loops if possible.
- vs Sealeds: Enums for fixed, simple values; sealeds for complex data/behavior.
Pitfall: Overusing enums for states with data—prefer sealeds to avoid bloating.
For mids: Use enums for flags/configs, sealeds for results/states.
Modeling Finite State Spaces
Finite state spaces are perfect for sealeds/enums: UI screens, API states, game modes. They limit possibilities, enabling exhaustive handling.
Example: FSM for a door:
Kotlin
sealed class DoorState
object Open : DoorState()
object Closed : DoorState()
data class Locked(val key: String) : DoorState()
fun transition(current: DoorState, action: String): DoorState = when (current) {
Open -> when (action) {
"close" -> Closed
else -> Open // But prefer exhaustive actions too
}
Closed -> when (action) {
"open" -> Open
"lock" -> Locked("secret")
else -> Closed
}
is Locked -> when (action) {
"unlock" -> if (action == current.key) Closed else current
else -> current
}
}
fun main() {
var state: DoorState = Closed
state = transition(state, "open") // Open
state = transition(state, "close") // Closed
state = transition(state, "lock") // Locked
// Compiler ensures all states handled in transition
}
This models a finite automaton. Exhaustiveness ensures no undefined transitions.
Tips:
- Nest when for complex logic.
- Use enums for actions if finite.
- For larger spaces, consider state machines libs, but sealeds handle basics well.
Benefits: Reduces bugs in concurrent systems (e.g., coroutines) by forcing case coverage.
(Word count so far: ~2300)
Binary Compatibility Pitfalls
Binary compatibility (BC) is key for libraries. Sealeds can break BC if not careful.
Pitfalls:
- Adding Subtypes: Breaks exhaustive when in consumers—source compat ok, but binary? If consumer compiled against old version, new subtype causes runtime “no branch” if no else.
- Removing Subtypes: Breaks if consumers handle it.
- Changing Modifiers: Making a subtype non-data affects equals/hashCode.
Example:
Library v1:
Kotlin
sealed class Status
object Good : Status()
object Bad : Status()
Consumer compiles against v1, uses exhaustive when.
v2 adds Ugly:
Kotlin
object Ugly : Status()
If consumer runs with v2 JAR without recompiling, a Ugly instance hits no branch—crash if expression.
Fix: Add else in public APIs, or use @JvmStatic carefully. For BC, prefer open classes or provide adapters.
Kotlin’s KAPT/KSP help, but test BC with tools like Binary Compatibility Validator.
For mids: In libs, document sealed evolutions; use semantic versioning.
Designing Closed-world Models
Closed-world models assume a fixed set of possibilities—ideal for sealeds/enums. Think domains like payment statuses, user roles.
Design principles:
- Identify Finiteness: If “only these options,” use sealed.
- Layer Hierarchies: Top-level sealed, nested for categories.
- Combine with Generics: For type-safe results.
- Test Exhaustiveness: Write tests that fail on new subtypes (via reflection, but sparingly).
Full example: Payment system.
Kotlin
sealed interface PaymentResult<out T>
sealed interface Success<T> : PaymentResult<T>
data class Processed<T>(val receipt: T) : Success<T>()
object Refunded : Success<Nothing>()
sealed interface Failure : PaymentResult<Nothing>
data class Declined(val reason: String) : Failure()
object Timeout : Failure()
fun processPayment(): PaymentResult<String> = Processed("Receipt123")
fun handle(result: PaymentResult<*>) {
when (result) {
is Processed -> println("Success: ${result.receipt}")
Refunded -> println("Refunded")
is Declined -> println("Declined: ${result.reason}")
Timeout -> println("Timeout")
// Exhaustive across hierarchies
}
}
This closed model ensures safe handling. Extend by adding to failures/successes, compiler enforces updates.
In practice: Use for Redux-like states in apps, or API contracts.
Wrapping up: Sealed types and enums transform error-prone code into compiler-verified masterpieces. Experiment in your projects—start small, like replacing if-chains with exhaustive when. Questions? Drop a comment!
