Kotlin Sealed Classes Cheat Sheet
Covers declaring sealed classes/interfaces, exhaustive when expressions, sealed hierarchies for state modeling, and common Android/Compose patterns.
Declaring a Sealed Class
Restrict a type hierarchy to a fixed set of subtypes known at compile time.
sealed class Result<out T>data class Success<T>(val data: T) : Result<T>()data class Error(val message: String, val cause: Throwable? = null) : Result<Nothing>()object Loading : Result<Nothing>()// Kotlin 1.9+: sealed interfaces work the same waysealed interface UiStatedata class Content(val items: List<String>) : UiStatedata object Empty : UiState // 'data object' since 1.9 gives toString/equals for freedata object Loading2 : UiState
Exhaustive `when` Expressions
The compiler forces you to handle every subtype, no `else` needed.
fun render(result: Result<String>): String = when (result) { is Success -> "Data: ${result.data}" is Error -> "Failed: ${result.message}" Loading -> "Loading..." // no `else` branch required — compiler errors if a case is missing}// Adding a new subclass later triggers a compile error at every `when`// that isn't updated — this is the main safety win over open classes.
Nested & Local Subclasses
Sealed subclasses can be nested inside the parent or declared in the same file/package (Kotlin 1.5+).
sealed class NetworkResponse { sealed class Success : NetworkResponse() { data class Ok(val body: String) : Success() object NoContent : Success() } data class Failure(val code: Int) : NetworkResponse()}fun handle(r: NetworkResponse) = when (r) { is NetworkResponse.Success.Ok -> println(r.body) NetworkResponse.Success.NoContent -> println("204") is NetworkResponse.Failure -> println("error ${r.code}")}
Sealed Class vs Enum vs Sealed Interface
Quick reference for choosing the right construct.
- Enum class- fixed set of singleton instances, no per-case state or generics
- Sealed class- fixed set of subtypes that CAN carry different data/state per case
- Sealed interface- like sealed class but subtypes can also extend other classes
- data object- Kotlin 1.9+ singleton sealed subtype with generated equals/toString
- abstract class- open hierarchy, compiler can't enforce exhaustive when
- Cross-module sealed- subclasses must be in same module + package as of Kotlin 1.5
Sealed Classes with Generic Variance
Use declaration-site variance so a sealed hierarchy composes cleanly with Kotlin's type system.
sealed class Either<out L, out R> { data class Left<out L>(val value: L) : Either<L, Nothing>() data class Right<out R>(val value: R) : Either<Nothing, R>()}fun <L, R, T> Either<L, R>.fold( onLeft: (L) -> T, onRight: (R) -> T): T = when (this) { is Either.Left -> onLeft(value) is Either.Right -> onRight(value)}// out L / out R lets Either<String, Int> be assignable// wherever Either<Any, Any> is expected, mirroring Nothing as the// bottom type for the branch that isn't populated.
Smart-Casting the `when` Subject
Binding the sealed value to a local `val` inside the subject lets every branch smart-cast without repeating property access.
fun describe(state: UiState) = when (val s = state) { is Content -> "Loaded ${s.items.size} items" // s is smart-cast to Content Empty -> "Nothing here" Loading2 -> "Working..."}// Combine with `when` returning a value directly instead of// mutating an external var — keeps state transitions total// and avoids leaving branches unassigned by accident.
Recursive Sealed Hierarchies (Algebraic Data Types)
Sealed classes can reference themselves to model trees and ASTs, the same pattern used for expression evaluators.
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() data class Neg(val inner: Expr) : Expr()}tailrec fun eval(e: Expr): Double = when (e) { is Expr.Num -> e.value is Expr.Add -> eval(e.left) + eval(e.right) is Expr.Mul -> eval(e.left) * eval(e.right) is Expr.Neg -> -eval(e.inner)}// eval(Expr.Add(Expr.Num(1.0), Expr.Mul(Expr.Num(2.0), Expr.Num(3.0)))) == 7.0
Polymorphic Serialization with kotlinx.serialization
Sealed hierarchies serialize polymorphically out of the box when every subtype is @Serializable and registered via a sealed class discriminator.
@Serializablesealed class ApiEvent { @Serializable @SerialName("connected") data class Connected(val sessionId: String) : ApiEvent() @Serializable @SerialName("message") data class Message(val text: String, val from: String) : ApiEvent() @Serializable @SerialName("disconnected") data object Disconnected : ApiEvent()}val json = Json { classDiscriminator = "type" }val encoded = json.encodeToString(ApiEvent.serializer(), ApiEvent.Message("hi", "bob"))// {"type":"message","text":"hi","from":"bob"}val decoded = json.decodeFromString(ApiEvent.serializer(), encoded)
Sealed Class Gotchas & Compiler Behavior
Edge cases that trip up developers migrating from open hierarchies.
- Exhaustiveness only in expression position- a `when` used as a statement doesn't force exhaustiveness; assign it to a val or return it to get the compiler check
- Adding a subtype is source-breaking- every exhaustive `when` across all consuming modules must add a branch, unlike an `else`-guarded open hierarchy
- Sealed + reflection- `KClass.sealedSubclasses` lets you enumerate all direct subtypes at runtime for registries or UI pickers
- Private constructors- sealed classes implicitly have a non-public constructor; you cannot instantiate the base type directly
- Sealed interfaces + multiple inheritance- a class can implement several sealed interfaces, but each interface's subtype set must still be closed to its own module
- Binary compatibility- adding a new sealed subtype is a binary-incompatible change for library consumers; treat it like adding an enum constant
Use sealed classes to model network/UI state (Loading/Success/Error) instead of nullable flags or booleans — it makes invalid states like 'loading AND has data' unrepresentable at compile time.