Enums, Sealed Classes & Interfaces
Some values come from a fixed list: the sizes on a T-shirt order form, the coach classes on a train, the states of a screen that is loading data. If you store these as plain strings, a typo like "Medum" slips through unnoticed. Kotlin gives you two tools that let the compiler check such values for you.
Enum classes describe a fixed set of constants. Sealed classes and interfaces describe a fixed set of types, each of which can carry its own data. Both work with when so that the compiler can tell you when you forgot a case. In this lesson you will learn both and when to choose each.
Enum classes
An enum class lists every possible value up front. Each value is an object called an enum constant, written in capitals by convention.
enum class Size { SMALL, MEDIUM, LARGE }
fun main() {
val chosen = Size.MEDIUM
println(chosen)
println(chosen.name)
println(chosen.ordinal)
println(Size.entries)
println(Size.valueOf("LARGE"))
println(chosen == Size.MEDIUM)
}MEDIUM MEDIUM 1 [SMALL, MEDIUM, LARGE] LARGE true
nameis the constant's name as a string, andordinalis its position (from 0).Size.entriesis a list of all constants, in declaration order.Size.valueOf("LARGE")turns a string into a constant. It throws an exception for unknown names, so for user input you might searchentriesinstead:Size.entries.find { it.name == text }.
A variable of type Size can never hold anything except these three values, so typos become compile errors.
Enums with properties and functions
Enum constants can carry data through a constructor, and the enum can have functions. When members follow the constants, end the list of constants with a semicolon.
enum class Coach(val label: String, val farePerKm: Double) {
SLEEPER("Sleeper", 0.5),
AC3("AC 3-tier", 1.3),
AC2("AC 2-tier", 1.9);
fun fareFor(km: Int) = (farePerKm * km).toInt()
}
fun main() {
for (coach in Coach.entries) {
println("${coach.label}: Rs ${coach.fareFor(500)}")
}
val selected = Coach.AC3
val perks = when (selected) {
Coach.SLEEPER -> "No bedding"
Coach.AC3, Coach.AC2 -> "Bedding included"
}
println(perks)
}Sleeper: Rs 250 AC 3-tier: Rs 650 AC 2-tier: Rs 950 Bedding included
The when has no else branch, and it does not need one: it covers every constant, so the compiler knows it is exhaustive. If someone later adds FIRST_AC, this when stops compiling until it is handled. With an else branch, the new constant would silently fall into else. That is why experienced Kotlin developers avoid else when matching enums.
Sealed interfaces and sealed classes
Enum constants all have the same shape. But what if each case needs different data? A screen that loads train results is either loading (no data), successful (a list of trains) or failed (an error message and a retry option). A sealed hierarchy models exactly this: a parent type whose subtypes are all known at compile time.
sealed interface LoadState {
data object Loading : LoadState
data class Success(val trains: List<String>) : LoadState
data class Error(val message: String, val canRetry: Boolean) : LoadState
}
fun render(state: LoadState): String = when (state) {
LoadState.Loading -> "Showing spinner..."
is LoadState.Success -> "Found ${state.trains.size}: ${state.trains.joinToString()}"
is LoadState.Error ->
if (state.canRetry) "Error: ${state.message} [Retry]" else "Error: ${state.message}"
}
fun main() {
val states = listOf(
LoadState.Loading,
LoadState.Success(listOf("Shatabdi", "Rajdhani")),
LoadState.Error("No internet", canRetry = true),
)
states.forEach { println(render(it)) }
println(states[1])
}Showing spinner... Found 2: Shatabdi, Rajdhani Error: No internet [Retry] Success(trains=[Shatabdi, Rajdhani])
- All direct subtypes of a sealed type must be declared in the same module and package (in practice, usually the same file). Nobody else can add a new subtype later, so the list is closed.
Loadinghas no data, so it is adata object. The other cases are data classes with their own properties.- In each
isbranch,stateis smart cast, sostate.trainsandstate.messageare available. - The
whencovers all three subtypes, so it needs noelse.
This pattern (often called UI state) is extremely common in Android apps and in any code that returns "a result or an error".
Sealed classes with shared data
Use a sealed class instead of a sealed interface when all subtypes share some state or constructor logic. Here, every payment has an amount, but each kind carries extra details.
sealed class Payment(val amount: Int) {
class Upi(amount: Int, val vpa: String) : Payment(amount)
class Card(amount: Int, val last4: String) : Payment(amount)
class Cash(amount: Int) : Payment(amount)
}
fun fee(p: Payment): Int = when (p) {
is Payment.Upi -> 0
is Payment.Card -> p.amount * 2 / 100
is Payment.Cash -> 0
}
fun describe(p: Payment): String = when (p) {
is Payment.Upi -> "UPI to ${p.vpa}"
is Payment.Card -> "Card ending ${p.last4}"
is Payment.Cash -> "Cash"
}
fun main() {
val payments = listOf(
Payment.Upi(850, "kabir@upi"),
Payment.Card(2500, "4421"),
Payment.Cash(300),
)
for (p in payments) {
println("${describe(p)}: Rs ${p.amount}, fee Rs ${fee(p)}")
}
}UPI to kabir@upi: Rs 850, fee Rs 0 Card ending 4421: Rs 2500, fee Rs 50 Cash: Rs 300, fee Rs 0
Imagine the shop adds wallet payments next month. You add class Wallet(...) : Payment(amount), and the compiler immediately reports that both fee and describe are no longer exhaustive. In a large app with dozens of such when expressions, this is how you avoid forgetting one.
A sealed class is abstract (you cannot create a Payment directly), and its constructor is protected or private. A class can implement several sealed interfaces but extend only one sealed class, so sealed interfaces are the more flexible default.
Enum or sealed?
| Question | Enum class | Sealed class or interface |
|---|---|---|
| Is every case the same shape? | Yes | Not necessarily |
| Can cases carry different data? | No, same properties for all | Yes |
| How many instances per case? | Exactly one | Many (or one with object) |
| List all cases at run time? | entries | Not built in |
Exhaustive when? | Yes | Yes |
Use an enum for simple labels (sizes, days, coach classes). Use a sealed type when the cases hold different information (states, results, commands).
Common mistakes
- Using strings or integers for fixed choices.
"medium"versus"Medium"bugs disappear with an enum. - Adding
elseto everywhen. It hides missing cases. Leave it out for enums and sealed types so the compiler can help. - Forgetting the semicolon after the last enum constant when the enum has functions or properties below.
- Using an enum when cases need different data. If you find yourself adding nullable properties that only some constants use, switch to a sealed hierarchy.
Key points
enum classdefines a fixed set of constants withname,ordinal,entriesandvalueOf.- Enum constants can have properties and functions; end the constant list with
;before members. - Sealed classes and interfaces define closed hierarchies whose subtypes are known at compile time.
- Sealed subtypes can be data classes, data objects or regular classes, each with its own data.
whenover an enum or sealed type is exhaustive withoutelse, so new cases cause compile errors where they are not handled.- Prefer
entriesover the oldervalues().
Exercise
Model the result of an OTP check as a sealed interface OtpResult with three cases: Verified (a data object), WrongCode(val attemptsLeft: Int) and Expired(val secondsAgo: Int). Write message(result: OtpResult): String with an exhaustive when: for WrongCode, say "Wrong code" plus the attempts left, or "Account locked" when none are left. Print the message for four sample results.
Show solution
sealed interface OtpResult {
data object Verified : OtpResult
data class WrongCode(val attemptsLeft: Int) : OtpResult
data class Expired(val secondsAgo: Int) : OtpResult
}
fun message(result: OtpResult): String = when (result) {
OtpResult.Verified -> "Phone number verified"
is OtpResult.WrongCode if result.attemptsLeft == 0 -> "Account locked"
is OtpResult.WrongCode -> "Wrong code, ${result.attemptsLeft} attempts left"
is OtpResult.Expired -> "Code expired ${result.secondsAgo} seconds ago"
}
fun main() {
val results = listOf(
OtpResult.WrongCode(2),
OtpResult.WrongCode(0),
OtpResult.Expired(45),
OtpResult.Verified,
)
results.forEach { println(message(it)) }
}Wrong code, 2 attempts left Account locked Code expired 45 seconds ago Phone number verified
The guard condition (Kotlin 2.2) handles the locked case first. Because the plain is OtpResult.WrongCode branch follows, the when is still exhaustive without else.
Quick Quiz
1. What does Size.entries return for enum class Size { S, M, L }?
entries is a read-only list of every constant in declaration order.
2. Why is it better to leave out else in a when over a sealed type?
Without else, exhaustiveness checking forces you to handle new cases.
3. When should you choose a sealed hierarchy over an enum?
Enum constants share one shape; sealed subtypes can each have their own properties.
4. Where must the direct subtypes of a sealed interface be declared?
The set of subtypes is closed so the compiler knows them all.
Learn Kotlin with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
