Sealed types
Sealed classes and sealed interfaces admit closed type hierarchies — the compiler knows all possible subtypes at compile time. The principal benefit is exhaustive when expressions — the compiler verifies that all branches are covered, eliminating the need for catch-all else. Sealed types admit substantial algebraic data type (ADT) modelling — sum types like Result, Option, Either, state machines, and parser ASTs. The combination — sealed class/interface, exhaustive when, smart casts on is checks, the nested-class admission for grouping subtypes — is the substance of Kotlin’s discriminated-union mechanism. The conventional contemporary Kotlin discipline favours sealed types over open inheritance hierarchies for closed sets of variants.
Sealed classes
The sealed class admits a fixed set of subclasses:
sealed class Shape
class Circle(val radius: Double) : Shape()
class Square(val side: Double) : Shape()
class Triangle(val base: Double, val height: Double) : Shape()
The principal restriction: all direct subclasses must be in the same module (and, since Kotlin 1.5, the same package).
A sealed class is conceptually abstract and open — admits subclasses but forbids construction.
Sealed interfaces
Since Kotlin 1.5:
sealed interface Status
class Active : Status
class Inactive : Status
class Banned(val until: LocalDate) : Status
object Pending : Status // singleton case
The mechanism admits substantial mixin-style ADTs — a sealed interface may be extended by classes that already extend other types.
Exhaustive when
The principal benefit — when over a sealed type is exhaustive:
sealed class Shape {
class Circle(val radius: Double) : Shape()
class Square(val side: Double) : Shape()
class Triangle(val base: Double, val height: Double) : Shape()
}
fun area(shape: Shape): Double = when (shape) {
is Shape.Circle -> Math.PI * shape.radius * shape.radius
is Shape.Square -> shape.side * shape.side
is Shape.Triangle -> shape.base * shape.height / 2
// No else needed — compiler verifies exhaustiveness
}
If a new subclass is added without updating the when, the compiler reports an error. The mechanism admits substantial type-safe dispatch.
The smart cast (is Shape.Circle admits shape.radius access) admits substantial conciseness.
Sealed classes vs enums
The principal differences:
| Feature | enum class | sealed class |
|---|---|---|
| Fixed instances | Yes | No (unlimited instances per subclass) |
| Per-case data | Same fields for all cases | Distinct fields per subclass |
| Subclassing | Not admitted | Admitted (sealed hierarchy) |
| Methods | Same method set | Distinct per subclass |
Use enum class for finite, identical-shape values; sealed class for finite, distinct-shape values.
// Enum (each case has the same shape):
enum class Status {
ACTIVE,
INACTIVE,
BANNED;
fun isAllowed() = this == ACTIVE
}
// Sealed (each case has distinct data):
sealed class Result<out T> {
data class Success<T>(val value: T) : Result<T>()
data class Failure(val error: String) : Result<Nothing>()
object Pending : Result<Nothing>()
}
Common ADT patterns
Result<T> / Either<L, R>
sealed class Result<out T, out E> {
data class Success<T>(val value: T) : Result<T, Nothing>()
data class Failure<E>(val error: E) : Result<Nothing, E>()
inline fun <R> map(transform: (T) -> R): Result<R, E> = when (this) {
is Success -> Success(transform(value))
is Failure -> this
}
inline fun <R, F> flatMap(transform: (T) -> Result<R, F>): Result<R, F>
where F : E = when (this) {
is Success -> transform(value)
is Failure -> this
}
fun getOrNull(): T? = when (this) {
is Success -> value
is Failure -> null
}
}
fun divide(a: Int, b: Int): Result<Int, String> = when {
b == 0 -> Result.Failure("division by zero")
else -> Result.Success(a / b)
}
State machine
sealed class GameState {
object Idle : GameState()
data class Playing(val level: Int, val score: Int) : GameState()
data class GameOver(val finalScore: Int, val won: Boolean) : GameState()
}
fun describe(state: GameState): String = when (state) {
GameState.Idle -> "Ready to play"
is GameState.Playing -> "Level ${state.level}, score ${state.score}"
is GameState.GameOver -> if (state.won) "Won! ${state.finalScore}"
else "Lost. ${state.finalScore}"
}
Discriminated union via sealed interface
sealed interface Event {
data class Click(val x: Int, val y: Int) : Event
data class KeyPress(val key: String) : Event
data class Scroll(val delta: Int) : Event
object Tick : Event
}
fun handle(event: Event): Action = when (event) {
is Event.Click -> Action.Click(event.x, event.y)
is Event.KeyPress -> Action.Type(event.key)
is Event.Scroll -> Action.Scroll(event.delta)
Event.Tick -> Action.Update
}
Recursive ADT
sealed class Expr {
data class Literal(val value: Int) : 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 expr: Expr) : Expr()
}
fun eval(expr: Expr): Int = when (expr) {
is Expr.Literal -> expr.value
is Expr.Add -> eval(expr.left) + eval(expr.right)
is Expr.Mul -> eval(expr.left) * eval(expr.right)
is Expr.Neg -> -eval(expr.expr)
}
val e = Expr.Add(Expr.Literal(2), Expr.Mul(Expr.Literal(3), Expr.Literal(4)))
println(eval(e)) // 14
The pattern admits substantial AST-style data structures.
Type-safe error hierarchy
sealed class AppError {
sealed class Network : AppError() {
object Timeout : Network()
data class StatusCode(val code: Int) : Network()
data class Connection(val cause: Throwable) : Network()
}
sealed class Validation : AppError() {
data class MissingField(val field: String) : Validation()
data class InvalidFormat(val field: String, val reason: String) : Validation()
}
data class Unknown(val message: String) : AppError()
}
fun describe(error: AppError): String = when (error) {
is AppError.Network.Timeout -> "Network timeout"
is AppError.Network.StatusCode -> "HTTP ${error.code}"
is AppError.Network.Connection -> "Connection: ${error.cause.message}"
is AppError.Validation.MissingField -> "Missing: ${error.field}"
is AppError.Validation.InvalidFormat -> "Invalid ${error.field}: ${error.reason}"
is AppError.Unknown -> error.message
}
The nested sealed class admits substantial hierarchical organisation.
Loading state
sealed class LoadState<out T> {
object Idle : LoadState<Nothing>()
object Loading : LoadState<Nothing>()
data class Loaded<T>(val data: T) : LoadState<T>()
data class Failed(val error: Throwable) : LoadState<Nothing>()
}
@Composable
fun render(state: LoadState<List<Item>>) {
when (state) {
LoadState.Idle -> Text("Click to load")
LoadState.Loading -> ProgressIndicator()
is LoadState.Loaded -> List(state.data)
is LoadState.Failed -> ErrorView(state.error)
}
}
Validation result
sealed class ValidationResult {
object Valid : ValidationResult()
data class Invalid(val errors: List<String>) : ValidationResult()
}
fun validate(form: Form): ValidationResult {
val errors = mutableListOf<String>()
if (form.name.isBlank()) errors.add("name required")
if (form.email.isBlank()) errors.add("email required")
if (form.age < 0) errors.add("age must be non-negative")
return if (errors.isEmpty()) ValidationResult.Valid
else ValidationResult.Invalid(errors)
}
Tree structure
sealed interface Tree<out T> {
data class Leaf<T>(val value: T) : Tree<T>
data class Branch<T>(val left: Tree<T>, val right: Tree<T>) : Tree<T>
object Empty : Tree<Nothing>
}
fun <T : Comparable<T>> contains(tree: Tree<T>, target: T): Boolean = when (tree) {
is Tree.Empty -> false
is Tree.Leaf -> tree.value == target
is Tree.Branch -> contains(tree.left, target) || contains(tree.right, target)
}
Sealed types with generics
sealed class Either<out L, out R> {
data class Left<L>(val value: L) : Either<L, Nothing>()
data class Right<R>(val value: R) : Either<Nothing, R>()
fun <T> fold(onLeft: (L) -> T, onRight: (R) -> T): T = when (this) {
is Left -> onLeft(value)
is Right -> onRight(value)
}
}
fun parse(s: String): Either<String, Int> = when {
s.isEmpty() -> Either.Left("empty")
else -> s.toIntOrNull()?.let { Either.Right(it) } ?: Either.Left("not numeric")
}
val result = parse("42").fold(
onLeft = { error -> "Error: $error" },
onRight = { value -> "Got: $value" }
)
Sealed for closed-set polymorphism
sealed interface Animal {
fun speak(): String
}
class Dog : Animal {
override fun speak() = "Woof!"
}
class Cat : Animal {
override fun speak() = "Meow!"
}
class Cow : Animal {
override fun speak() = "Moo!"
}
// Conventional polymorphism:
fun greet(animal: Animal) = animal.speak()
// Or pattern-match:
fun describe(animal: Animal): String = when (animal) {
is Dog -> "I'm a dog: ${animal.speak()}"
is Cat -> "I'm a cat: ${animal.speak()}"
is Cow -> "I'm a cow: ${animal.speak()}"
}
Sealed type with subclasses in different files
Pre-Kotlin 1.5, sealed-class subclasses had to be in the same file. Since 1.5, they may be in the same package and module:
src/main/kotlin/com/example/
├── Result.kt (sealed class Result)
├── ResultSuccess.kt (class Success : Result)
└── ResultFailure.kt (class Failure : Result)
The sealed-class subclasses must be in the same package as the parent.
Common patterns
Coroutine result
sealed class CoroutineResult<out T> {
data class Success<T>(val value: T) : CoroutineResult<T>()
data class Failure(val cause: Throwable) : CoroutineResult<Nothing>()
object Cancelled : CoroutineResult<Nothing>()
}
suspend fun <T> safe(block: suspend () -> T): CoroutineResult<T> = try {
CoroutineResult.Success(block())
} catch (e: CancellationException) {
CoroutineResult.Cancelled
} catch (e: Throwable) {
CoroutineResult.Failure(e)
}
Parser result
sealed class ParseResult<out T> {
data class Ok<T>(val value: T, val remaining: String) : ParseResult<T>()
data class Err(val message: String, val position: Int) : ParseResult<Nothing>()
}
fun parseInt(input: String, position: Int): ParseResult<Int> {
val match = Regex("""^\d+""").find(input)
return if (match != null)
ParseResult.Ok(match.value.toInt(), input.substring(match.range.last + 1))
else
ParseResult.Err("expected number", position)
}
Action / event types
sealed class Action {
data class Increment(val by: Int = 1) : Action()
data class Decrement(val by: Int = 1) : Action()
object Reset : Action()
data class SetTo(val value: Int) : Action()
}
fun reduce(state: Int, action: Action): Int = when (action) {
is Action.Increment -> state + action.by
is Action.Decrement -> state - action.by
Action.Reset -> 0
is Action.SetTo -> action.value
}
Option<T> (Kotlin’s standard Option-like type is just T?, but for explicit modelling):
sealed class Option<out T> {
object None : Option<Nothing>()
data class Some<T>(val value: T) : Option<T>()
inline fun <R> map(transform: (T) -> R): Option<R> = when (this) {
is None -> None
is Some -> Some(transform(value))
}
fun getOrElse(default: T): T = when (this) {
is None -> default
is Some -> value
}
}
In idiomatic Kotlin, T? is the conventional alternative — Option is rarely defined explicitly.
Mutually exclusive variants
sealed interface PaymentMethod {
data class CreditCard(val number: String, val cvv: String) : PaymentMethod
data class BankTransfer(val accountNumber: String) : PaymentMethod
data class CryptoWallet(val address: String) : PaymentMethod
}
fun process(method: PaymentMethod): Receipt = when (method) {
is PaymentMethod.CreditCard -> processCard(method.number, method.cvv)
is PaymentMethod.BankTransfer -> processBank(method.accountNumber)
is PaymentMethod.CryptoWallet -> processCrypto(method.address)
}
A note on the conventional discipline
The contemporary Kotlin sealed-types advice:
- Use sealed classes/interfaces for closed sets of variants.
- Prefer sealed interfaces over sealed classes — admit substantial mixin design.
- Use
data classfor substantial data-carrying variants. - Use
objectfor stateless variants (singletons). - Use sealed types with
when— admit exhaustiveness checking. - Use generics with sealed types for substantial reuse (Result, Either, Option).
- Use nested types for hierarchical organisation.
- Avoid
elseinwhenover sealed types — admit compiler-checked exhaustiveness. - Avoid sealed for unbounded sets of subclasses —
openis conventional.
The combination — sealed class/interface for closed hierarchies, exhaustive when with smart casts, the substantial generic integration, the nested-types admission, the data class/object integration — is the substance of Kotlin’s algebraic-data-types mechanism. The discipline produces substantial type-safe code with substantial expressiveness for state machines, ASTs, and discriminated unions.