Skip to content
ioob.dev
Go back

Kotlin Part 8 — sealed class and enum

· 4 min read
Kotlin Series (8/12)
  1. Kotlin Beginner Part 1 — Variables and Types
  2. Kotlin Beginner Part 2 — Conditionals and Loops
  3. Kotlin Beginner Part 3 — Functions
  4. Kotlin Beginner Part 4 — Classes and Objects
  5. Kotlin Beginner Part 5 — Collections and Lambdas
  6. Kotlin Part 6 — Null Safety Advanced
  7. Kotlin Part 7 — Generics
  8. Kotlin Part 8 — sealed class and enum
  9. Kotlin Part 9 — Coroutines Basics
  10. Kotlin Part 10 — Coroutines Advanced
  11. Kotlin Part 11 — DSL and Advanced Functions
  12. Kotlin Part 12 — Practical Patterns
Table of contents

Table of contents

Representing State in Code

In programming, you frequently encounter situations like “this value is one of A, B, or C.” HTTP status codes, payment statuses, network request results, and so on. You could represent them as string or integer constants, but a single typo can introduce bugs.

Kotlin provides two tools for representing these “limited options” through the type system: enum class and sealed class.

enum class

Let’s start with the simplest form. Use this when the options are fixed and each has the same structure.

enum class Direction {
    NORTH, SOUTH, EAST, WEST
}

fun move(direction: Direction) {
    when (direction) {
        Direction.NORTH -> println("Moving up")
        Direction.SOUTH -> println("Moving down")
        Direction.EAST  -> println("Moving right")
        Direction.WEST  -> println("Moving left")
    }
}

fun main() {
    move(Direction.NORTH)  // Moving up
}

It’s not much different from Java’s enum. However, Kotlin enums really shine when used with when, which we’ll cover in detail later.

Adding Properties and Methods to enums

You can attach values to enum constants or define methods.

enum class Color(val hex: String) {
    RED("#FF0000"),
    GREEN("#00FF00"),
    BLUE("#0000FF");

    fun rgb(): Triple<Int, Int, Int> {
        val r = hex.substring(1, 3).toInt(16)
        val g = hex.substring(3, 5).toInt(16)
        val b = hex.substring(5, 7).toInt(16)
        return Triple(r, g, b)
    }
}

fun main() {
    val color = Color.RED
    println("${color.name}: ${color.hex}")  // RED: #FF0000
    println("RGB: ${color.rgb()}")           // RGB: (255, 0, 0)

    // Enum utility functions
    println(Color.entries)                    // [RED, GREEN, BLUE]
    println(Color.valueOf("BLUE").hex)        // #0000FF
}

One thing to watch out for: you must put a semicolon (;) after the last constant. This syntax separates the constant list from the methods — forgetting it causes a compile error.

entries is a property introduced in Kotlin 1.9 that replaces the old values(). It returns a list instead of an array, making it safer and more convenient.

Limitations of enum

Enums are powerful but have one fundamental constraint: all constants must have the same structure.

Consider a network request result. On success there’s data, on failure there’s an error message, and during loading there’s nothing. Each state carries different data.

// This cannot be expressed with an enum
// enum class Result {
//     SUCCESS(data: Any),     // Has data
//     ERROR(message: String), // Has an error message
//     LOADING                 // No data
// }

sealed class solves this problem.

sealed class

A sealed class declares “the subtypes of this class are predetermined.” Like enums, the options are limited, but each option can carry different data.

sealed class Result<out T> {
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val message: String, val code: Int = -1) : Result<Nothing>()
    data object Loading : Result<Nothing>()
}

fun handleResult(result: Result<String>) {
    when (result) {
        is Result.Success -> println("Success: ${result.data}")
        is Result.Error   -> println("Failure(${result.code}): ${result.message}")
        is Result.Loading -> println("Loading...")
    }
}

fun main() {
    handleResult(Result.Success("Hello Kotlin"))
    // Success: Hello Kotlin

    handleResult(Result.Error("Server error", 500))
    // Failure(500): Server error

    handleResult(Result.Loading)
    // Loading...
}

Success carries generic data, Error carries a message and code, and Loading carries no data at all. The compiler knows these three are the entirety of Result.

Drawing the Result hierarchy as a class diagram clearly shows the “closed three-way” structure of sealed.

classDiagram
    class Result~T~ {
        <<sealed>>
    }
    class Success~T~ {
        +T data
    }
    class Error {
        +String message
        +Int code
    }
    class Loading {
        <<data object>>
    }
    Result <|-- Success
    Result <|-- Error
    Result <|-- Loading

Exhaustive when Matching

The key strength of sealed classes shows in when. When all subtypes are handled, else is unnecessary, and if a new subtype is added, the compiler reports an error wherever it’s not handled.

sealed class PaymentStatus {
    data object Pending : PaymentStatus()
    data object Approved : PaymentStatus()
    data object Rejected : PaymentStatus()
    data class Refunded(val amount: Int) : PaymentStatus()
}

fun describeStatus(status: PaymentStatus): String {
    // Handle all cases without else
    return when (status) {
        is PaymentStatus.Pending  -> "Payment pending"
        is PaymentStatus.Approved -> "Payment completed"
        is PaymentStatus.Rejected -> "Payment rejected"
        is PaymentStatus.Refunded -> "${status.amount} won refunded"
    }
}

fun main() {
    println(describeStatus(PaymentStatus.Pending))        // Payment pending
    println(describeStatus(PaymentStatus.Refunded(5000)))  // 5000 won refunded
}

What happens if you add a new Cancelled state?

// data object Cancelled : PaymentStatus()  // If you add this,
// the describeStatus function gets a compile error — Cancelled is not handled

If you had lumped everything into else, the new state would have been silently ignored. Sealed classes and exhaustive matching prevent such mistakes at the source. The more frequently states change in a domain, the more this advantage shines.

sealed interface

Since Kotlin 1.5, sealed interface is also supported. The difference from sealed class is that subtypes can implement multiple sealed interfaces simultaneously.

sealed interface Error {
    val message: String
}

sealed interface Recoverable

data class NetworkError(
    override val message: String,
    val retryAfter: Int
) : Error, Recoverable

data class AuthError(
    override val message: String
) : Error, Recoverable

data class FatalError(
    override val message: String,
    val stackTrace: String
) : Error  // Not Recoverable

fun handleError(error: Error) {
    when (error) {
        is NetworkError -> println("Network error (retry after ${error.retryAfter}s)")
        is AuthError    -> println("Auth error — re-login required")
        is FatalError   -> println("Fatal error: ${error.stackTrace}")
    }
}

fun retryIfPossible(error: Error) {
    if (error is Recoverable) {
        println("'${error.message}' — recovery possible")
    } else {
        println("'${error.message}' — not recoverable")
    }
}

fun main() {
    val errors: List<Error> = listOf(
        NetworkError("Timeout", 30),
        AuthError("Token expired"),
        FatalError("OOM", "java.lang.OutOfMemoryError...")
    )

    errors.forEach { error ->
        handleError(error)
        retryIfPossible(error)
        println()
    }
}

NetworkError and AuthError are both Error and Recoverable at the same time. A class can only inherit from one class, but can implement multiple interfaces, making this kind of cross-classification possible.

Choosing Between enum and sealed class

Both represent “limited options,” but their use cases differ.

Criterionenum classsealed class/interface
Data per optionAll have the same structureEach can differ
Instance countExactly one per optionMultiple per option possible
entries / valueOfSupportedNot supported
SerializationSimpleDepends on structure

As a rule of thumb, simple flag-like values (direction, color, grade) fit enum, while cases where each option carries different data (API responses, error types, UI states) suit sealed class.

Practical Pattern: UI State Management

Here’s a pattern commonly seen in Android and Compose.

sealed class UiState<out T> {
    data object Idle : UiState<Nothing>()
    data object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val throwable: Throwable) : UiState<Nothing>()
}

// In a ViewModel
class UserViewModel {
    private var state: UiState<List<String>> = UiState.Idle

    fun loadUsers() {
        state = UiState.Loading
        try {
            val users = listOf("Kim", "Lee", "Park")  // Assume API call
            state = UiState.Success(users)
        } catch (e: Exception) {
            state = UiState.Error(e)
        }
    }

    fun render() {
        when (val s = state) {
            is UiState.Idle    -> println("Idle")
            is UiState.Loading -> println("Loading...")
            is UiState.Success -> println("Users: ${s.data.joinToString()}")
            is UiState.Error   -> println("Error: ${s.throwable.message}")
        }
    }
}

fun main() {
    val vm = UserViewModel()
    vm.render()      // Idle
    vm.loadUsers()
    vm.render()      // Users: Kim, Lee, Park
}

UiState defines all possible screen states as a sealed class. When a new state is added, the compiler catches any when block that doesn’t handle it. This pattern is also at the heart of MVI architecture.

Drawing the state transitions as a state diagram makes the UI flow clearly visible.

stateDiagram-v2
    [*] --> Idle
    Idle --> Loading: loadUsers()
    Loading --> Success: API response OK
    Loading --> Error: Exception thrown
    Success --> Loading: Refresh
    Error --> Loading: Retry
    Success --> [*]
    Error --> [*]

Sealed classes and enums dramatically increase the expressiveness of Kotlin’s type system. Because the compiler verifies the constraint “this variable is one of these states,” you can prevent unexpected states from sneaking in at runtime.

The next part covers the basics of coroutines. Let’s enter the world of asynchronous programming.

-> Part 9: Coroutines Basics


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Kotlin Part 7 — Generics
Next Post
Kotlin Part 9 — Coroutines Basics