Enums, Sealed Classes & Interfaces

Kotlin · Lesson 17 of 29 7 min read Updated

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.

Kotlin
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)
}
Output
MEDIUM
MEDIUM
1
[SMALL, MEDIUM, LARGE]
LARGE
true
  • name is the constant's name as a string, and ordinal is its position (from 0).
  • Size.entries is 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 search entries instead: 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.

Kotlin
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)
}
Output
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.

Kotlin
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])
}
Output
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.
  • Loading has no data, so it is a data object. The other cases are data classes with their own properties.
  • In each is branch, state is smart cast, so state.trains and state.message are available.
  • The when covers all three subtypes, so it needs no else.

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.

Kotlin
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)}")
    }
}
Output
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?

QuestionEnum classSealed class or interface
Is every case the same shape?YesNot necessarily
Can cases carry different data?No, same properties for allYes
How many instances per case?Exactly oneMany (or one with object)
List all cases at run time?entriesNot built in
Exhaustive when?YesYes

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 else to every when. 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 class defines a fixed set of constants with name, ordinal, entries and valueOf.
  • 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.
  • when over an enum or sealed type is exhaustive without else, so new cases cause compile errors where they are not handled.
  • Prefer entries over the older values().

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
Kotlin
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)) }
}
Output
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 }?

2. Why is it better to leave out else in a when over a sealed type?

3. When should you choose a sealed hierarchy over an enum?

4. Where must the direct subtypes of a sealed interface be declared?

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