cyberangles guide

Unpacking Kotlin’s Sealed Classes: A Comprehensive Guide

In the world of Kotlin, certain features stand out for their ability to enforce robustness, clarity, and maintainability in code. **Sealed classes** are one such feature. Designed to represent **restricted class hierarchies**, sealed classes empower developers to define a fixed set of possible subclasses, ensuring that all potential cases are explicitly handled. This makes them invaluable for scenarios like state management, result handling, and modeling algebraic data types (ADTs)—common in functional programming and modern app development. Whether you’re building a UI with distinct states (e.g., loading, success, error) or parsing API responses, sealed classes provide a type-safe way to restrict subclassing and enable exhaustive checks. In this blog, we’ll dive deep into sealed classes: their syntax, key characteristics, use cases, and how they compare to enums and open classes. By the end, you’ll have a clear understanding of when and how to leverage sealed classes to write more predictable and error-resistant code.

Table of Contents

What Are Sealed Classes?

A sealed class in Kotlin is a special type of class that restricts the creation of subclasses. Unlike regular open classes (which allow subclassing anywhere), sealed classes enforce that all direct subclasses must be declared in the same file as the sealed class itself (or as nested classes within it). This restriction creates a closed hierarchy, meaning the set of possible subclasses is known and fixed at compile time.

At their core, sealed classes are designed to represent fixed type hierarchies where you want to ensure all possible variants are explicitly defined and accounted for. Think of them as a more flexible alternative to enums, enabling richer state representation while maintaining the safety of a closed set of options.

Syntax and Basic Usage

Defining a Sealed Class

To declare a sealed class, use the sealed modifier. Sealed classes are implicitly abstract, so you cannot instantiate them directly. Instead, you define subclasses (either nested or in the same file) to represent specific variants.

Example: A Simple Sealed Class

// Sealed class declaration (abstract by default)
sealed class UiState

// Subclasses declared in the same file (can be nested or top-level)
object Loading : UiState()
data class Success(val data: String) : UiState()
data class Error(val message: String, val code: Int) : UiState()

Key Syntax Notes:

  1. No Direct Instantiation: You cannot create an instance of UiState itself (e.g., UiState() will throw a compile error).
  2. Subclass Location: Subclasses must be in the same file as the sealed class. Prior to Kotlin 1.1, subclasses had to be nested, but modern Kotlin allows top-level subclasses in the same file.
  3. Subclass Types: Subclasses can be objects (singletons), data classes (for stateful variants), regular classes, or even other sealed classes.

Key Characteristics

Sealed classes have several defining traits that make them unique:

1. Restricted Subclassing

All direct subclasses must be declared in the same file as the sealed class. This ensures the hierarchy is closed and known at compile time.

2. Exhaustive when Statements

The Kotlin compiler recognizes sealed class hierarchies as closed, enabling exhaustive checks in when expressions. If you use a when expression with a sealed class, the compiler will enforce that all possible subclasses are handled, eliminating runtime errors from missing cases.

Example: Exhaustive when Check

fun handleUiState(state: UiState): String = when (state) {
    is Loading -> "Loading..."
    is Success -> "Data loaded: ${state.data}"
    is Error -> "Error (${state.code}): ${state.message}"
    // No "else" clause needed—compiler verifies all subclasses are covered
}

If you later add a new subclass (e.g., PartialLoad), the compiler will flag all when expressions that don’t handle it, preventing silent failures.

3. Abstract by Default

Sealed classes cannot be instantiated directly. They are implicitly abstract, so you must use their subclasses to represent concrete cases.

4. Flexible Subclass Types

Subclasses of sealed classes can be:

  • objects (for singleton states like Loading).
  • data classes (for stateful variants with equals(), hashCode(), and toString() auto-generated).
  • Regular classes (for complex logic).
  • Even other sealed classes or open classes (to allow further subclassing, though restricted to the same file).

Sealed Classes vs. Enums vs. Open Classes

Understanding how sealed classes compare to enums and open classes is critical for choosing the right tool for the job. Let’s break down their differences:

FeatureSealed ClassesEnumsOpen Classes
PurposeFixed set of types (with optional state)Fixed set of instances (singletons)Unrestricted subclassing (anywhere)
InstantiationAbstract (instantiate subclasses only)Singleton instances (enum constants)Directly instantiable
StateSubclasses can hold state (e.g., data class Success(val data: T))No per-instance state (constants are fixed)Unlimited state flexibility
ExhaustivenessEnforced by compiler in when expressionsEnforced by compiler in when expressionsNot enforced (unknown subclasses possible)
Use CaseState management, result handling, ADTsFixed constants (e.g., Color.RED)General hierarchies (e.g., AnimalDog)

When to Use Which?

  • Enums: Use when you need a fixed set of singleton values with no state (e.g., Direction.NORTH, PaymentMethod.CREDIT_CARD).
  • Sealed Classes: Use when you need a fixed set of types with varying state (e.g., Success(data), Error(message)).
  • Open Classes: Use when you want to allow unrestricted subclassing (e.g., library APIs designed for extension).

Practical Use Cases

Sealed classes shine in scenarios where you need to model a fixed set of related states or outcomes. Here are some common use cases:

1. UI State Management

Sealed classes are ideal for representing UI states, where the interface can only be in one of a few distinct states (e.g., loading, success, error).

sealed class ScreenState {
    object Loading : ScreenState()
    data class Content(val items: List<String>) : ScreenState()
    data class Error(val message: String, val retryAction: () -> Unit) : ScreenState()
}

// Usage in a ViewModel or UI controller
fun renderState(state: ScreenState) {
    when (state) {
        is ScreenState.Loading -> showLoadingSpinner()
        is ScreenState.Content -> displayItems(state.items)
        is ScreenState.Error -> showError(state.message, state.retryAction)
    }
}

2. Result Handling

APIs or function calls often return one of two outcomes: success with data or failure with an error. Sealed classes model this cleanly.

sealed class ApiResult<out T> {
    data class Success<out T>(val data: T) : ApiResult<T>()
    data class Error(val exception: Exception) : ApiResult<Nothing>()
    object Loading : ApiResult<Nothing>()
}

// Usage with a repository
suspend fun fetchUser(): ApiResult<User> {
    return try {
        val user = apiService.getUser()
        ApiResult.Success(user)
    } catch (e: Exception) {
        ApiResult.Error(e)
    }
}

3. Algebraic Data Types (ADTs)

In functional programming, sealed classes enable sum types (algebraic data types), where a value can be one of several types. For example, a Shape could be a Circle, Square, or Triangle.

sealed class Shape {
    data class Circle(val radius: Double) : Shape()
    data class Square(val sideLength: Double) : Shape()
    data class Triangle(val base: Double, val height: Double) : Shape()
}

fun calculateArea(shape: Shape): Double = when (shape) {
    is Shape.Circle -> Math.PI * shape.radius * shape.radius
    is Shape.Square -> shape.sideLength * shape.sideLength
    is Shape.Triangle -> 0.5 * shape.base * shape.height
}

4. Parser Implementations

Sealed classes help model token types in parsers, ensuring all token variants are explicitly handled.

sealed class Token {
    data class Identifier(val name: String) : Token()
    data class Number(val value: Int) : Token()
    object LParen : Token() // "("
    object RParen : Token() // ")"
    object Plus : Token()   // "+"
}

fun tokenize(input: String): List<Token> {
    // Logic to convert input into a list of Token subclasses
}

Advanced Scenarios

Sealed Interfaces (Kotlin 1.5+)

Kotlin 1.5 introduced sealed interfaces, extending the sealed concept to interfaces. Sealed interfaces work similarly to sealed classes but for interfaces, enabling closed hierarchies of interface implementations.

Example: Sealed Interface

sealed interface Operation {
    data class Add(val a: Int, val b: Int) : Operation
    data class Subtract(val a: Int, val b: Int) : Operation
    data class Multiply(val a: Int, val b: Int) : Operation
}

fun evaluate(op: Operation): Int = when (op) {
    is Operation.Add -> op.a + op.b
    is Operation.Subtract -> op.a - op.b
    is Operation.Multiply -> op.a * op.b
}

Sealed interfaces are useful when you want to enforce a closed set of implementations for an interface (e.g., mathematical operations, command patterns).

Nested Sealed Classes

Sealed classes can be nested inside other classes or interfaces, helping organize related hierarchies.

class Calculator {
    sealed class Operation {
        data class Add(val a: Int, val b: Int) : Operation()
        data class Subtract(val a: Int, val b: Int) : Operation()
    }

    fun compute(op: Operation): Int = when (op) {
        is Operation.Add -> op.a + op.b
        is Operation.Subtract -> op.a - op.b
    }
}

Open Subclasses of Sealed Classes

Subclasses of sealed classes can themselves be open, allowing further subclassing—but only within the same file.

sealed class Vehicle

open class Car : Vehicle() // Open for subclassing
class Sedan : Car() // Allowed (same file)
class SUV : Car() // Allowed (same file)

object Bicycle : Vehicle() // Singleton subclass

Common Pitfalls and Best Practices

Pitfalls to Avoid

  1. Forgetting Exhaustiveness in when Statements
    When using when as a statement (not an expression), the compiler only warns about missing cases. Use when as an expression (assign its result to a variable) to enforce exhaustiveness.

    // Bad: Compiler warns but doesn't error (statement form)
    fun handleState(state: UiState) {
        when (state) {
            is Loading -> showLoading()
            // Error and Success not handled—compiler warns but allows
        }
    }
    
    // Good: Compiler errors on missing cases (expression form)
    fun getStateMessage(state: UiState): String = when (state) {
        is Loading -> "Loading..."
        is Success -> "Success"
        is Error -> "Error" // All cases handled
    }
  2. Placing Subclasses in the Wrong File
    Subclasses must live in the same file as the sealed class. The compiler will throw an error if you try to declare a subclass elsewhere.

  3. Overusing Sealed Classes
    Don’t use sealed classes when enums suffice. For example, Color.RED is better as an enum than a sealed class with object Red : Color().

Best Practices

  1. Keep Hierarchies Small and Focused
    Avoid bloated sealed classes with dozens of subclasses. Split into smaller sealed classes if the hierarchy becomes unmanageable.

  2. Use data class for Stateful Subclasses
    data class auto-generates equals(), hashCode(), and toString(), making stateful subclasses easier to work with (e.g., data class Success(val data: T)).

  3. Document the Hierarchy
    Add comments to the sealed class and its subclasses to explain their purpose, especially if the hierarchy is non-trivial.

  4. Leverage Sealed Interfaces for Interface Hierarchies
    Use sealed interfaces when you need a closed set of interface implementations (e.g., Command patterns with fixed command types).

Conclusion

Kotlin’s sealed classes are a powerful tool for modeling fixed, closed hierarchies. By restricting subclassing and enabling exhaustive when checks, they promote type safety, reduce runtime errors, and make code more maintainable. Whether you’re handling UI states, API results, or algebraic data types, sealed classes provide a clean, expressive way to represent complex logic with clarity.

By choosing sealed classes over enums or open classes in the right scenarios, you’ll write code that’s not only robust but also self-documenting—making it easier for you and your team to reason about and extend over time.

References