Table of contents
- Representing State in Code
- enum class
- Adding Properties and Methods to enums
- Limitations of enum
- sealed class
- Exhaustive when Matching
- sealed interface
- Choosing Between enum and sealed class
- Practical Pattern: UI State Management
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.
| Criterion | enum class | sealed class/interface |
|---|---|---|
| Data per option | All have the same structure | Each can differ |
| Instance count | Exactly one per option | Multiple per option possible |
entries / valueOf | Supported | Not supported |
| Serialization | Simple | Depends 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.




Loading comments...