Polyglot
Languages Kotlin algebraic data types
Kotlin § algebraic-data-types

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:

Featureenum classsealed class
Fixed instancesYesNo (unlimited instances per subclass)
Per-case dataSame fields for all casesDistinct fields per subclass
SubclassingNot admittedAdmitted (sealed hierarchy)
MethodsSame method setDistinct 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 class for substantial data-carrying variants.
  • Use object for 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 else in when over sealed types — admit compiler-checked exhaustiveness.
  • Avoid sealed for unbounded sets of subclasses — open is 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.