In the previous tutorial, you learned about scope functions. Now let’s learn about three important types in Kotlin: enum classes, sealed classes, and value classes. Each one solves a different problem, and knowing when to use which will make your code better.

In this tutorial, you will learn:

  • Enum classes — fixed set of constants
  • Sealed classes — restricted hierarchies with different data
  • Sealed interfaces — multiple inheritance with sealed types
  • Value classes — type safety without runtime overhead
  • When to use which

Enum Classes

An enum class defines a fixed set of constants. Every value is known at compile time.

Basic Enum

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

val direction = Direction.NORTH
println(direction) // NORTH

Enum in when

When you use all enum values in a when expression, you do not need an else branch:

val message = when (direction) {
    Direction.NORTH -> "Going up"
    Direction.SOUTH -> "Going down"
    Direction.EAST -> "Going right"
    Direction.WEST -> "Going left"
}

If you add a new enum value later, the compiler will warn you about every when that does not handle it. This is a big advantage.

Enum with Properties and Methods

Enums can have properties, constructors, and methods:

enum class Planet(val mass: Double, val radius: Double) {
    MERCURY(3.303e+23, 2.4397e6),
    VENUS(4.869e+24, 6.0518e6),
    EARTH(5.976e+24, 6.37814e6),
    MARS(6.421e+23, 3.3972e6);

    fun surfaceGravity(): Double {
        val G = 6.67300E-11
        return G * mass / (radius * radius)
    }
}

println(Planet.EARTH.mass)            // 5.976E24
println(Planet.EARTH.surfaceGravity()) // ~9.8

Enum with Interface

Enums can implement interfaces:

enum class LogLevel(val severity: Int) : Printable {
    DEBUG(0) { override fun display() = "DEBUG" },
    INFO(1) { override fun display() = "INFO" },
    WARNING(2) { override fun display() = "WARNING" },
    ERROR(3) { override fun display() = "ERROR" };

    fun isHighSeverity(): Boolean = severity >= 2
}

println(LogLevel.ERROR.isHighSeverity()) // true
println(LogLevel.DEBUG.display())        // DEBUG

Enum as State Machine

Enums are great for state machines. You can define allowed transitions:

enum class OrderStatus {
    PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED;

    fun canTransitionTo(next: OrderStatus): Boolean {
        return when (this) {
            PENDING -> next == CONFIRMED || next == CANCELLED
            CONFIRMED -> next == SHIPPED || next == CANCELLED
            SHIPPED -> next == DELIVERED
            DELIVERED -> false
            CANCELLED -> false
        }
    }
}

val status = OrderStatus.PENDING
println(status.canTransitionTo(OrderStatus.CONFIRMED)) // true
println(status.canTransitionTo(OrderStatus.DELIVERED))  // false

Useful Enum Properties

Every enum has built-in properties:

// Get all values
println(Direction.entries) // [NORTH, SOUTH, EAST, WEST]

// Get by name
val dir = Direction.valueOf("EAST")
println(dir) // EAST

// Ordinal (position starting from 0)
println(Direction.NORTH.ordinal) // 0
println(Direction.EAST.ordinal)  // 2

// Name
println(Direction.NORTH.name) // NORTH

Sealed Classes

A sealed class is like an enum, but each subclass can hold different data. All subclasses must be defined in the same file.

Basic Sealed Class

sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String, val code: Int) : Result()
    data object Loading : Result()
}

Each subclass has different properties. Success has data, Error has message and code, and Loading has nothing.

Exhaustive when

Like enums, sealed classes work with exhaustive when:

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

println(handleResult(Result.Success("Data loaded")))
// Success: Data loaded

println(handleResult(Result.Error("Not found", 404)))
// Error 404: Not found

UI State Example

Sealed classes are perfect for representing UI state:

sealed class UiState {
    data object Idle : UiState()
    data object Loading : UiState()
    data class Content(val items: List<String>) : UiState()
    data class Error(val message: String) : UiState()
}

fun renderUi(state: UiState): String {
    return when (state) {
        is UiState.Idle -> "Waiting..."
        is UiState.Loading -> "Loading..."
        is UiState.Content -> "Showing ${state.items.size} items"
        is UiState.Error -> "Error: ${state.message}"
    }
}

Payment Method Example

Each payment type needs different data:

sealed class PaymentMethod {
    data class CreditCard(val number: String, val expiry: String) : PaymentMethod()
    data class BankTransfer(val iban: String) : PaymentMethod()
    data class PayPal(val email: String) : PaymentMethod()
    data object Cash : PaymentMethod()
}

fun processPayment(method: PaymentMethod): String {
    return when (method) {
        is PaymentMethod.CreditCard ->
            "Charging card ending in ${method.number.takeLast(4)}"
        is PaymentMethod.BankTransfer ->
            "Transferring to IBAN: ${method.iban}"
        is PaymentMethod.PayPal ->
            "Sending to ${method.email}"
        is PaymentMethod.Cash ->
            "Paying with cash"
    }
}

Generic Sealed Class

Sealed classes can use generics:

sealed class NetworkResponse<out T> {
    data class Success<T>(val data: T) : NetworkResponse<T>()
    data class Error(val message: String) : NetworkResponse<Nothing>()
    data object Loading : NetworkResponse<Nothing>()
}

Sealed Interfaces

Sealed interfaces are like sealed classes but allow multiple inheritance. A class can implement multiple sealed interfaces.

sealed interface Shape {
    fun area(): Double
}

data class Circle(val radius: Double) : Shape {
    override fun area(): Double = Math.PI * radius * radius
}

data class Rectangle(val width: Double, val height: Double) : Shape {
    override fun area(): Double = width * height
}

data class Triangle(val base: Double, val height: Double) : Shape {
    override fun area(): Double = 0.5 * base * height
}

Use them the same way as sealed classes:

val shapes: List<Shape> = listOf(
    Circle(5.0),
    Rectangle(4.0, 6.0),
    Triangle(3.0, 8.0)
)

for (shape in shapes) {
    val description = when (shape) {
        is Circle -> "Circle r=${shape.radius}"
        is Rectangle -> "Rectangle ${shape.width}x${shape.height}"
        is Triangle -> "Triangle b=${shape.base} h=${shape.height}"
    }
    println("$description -> area = ${"%.2f".format(shape.area())}")
}

Value Classes

A value class wraps a single value without runtime overhead. The compiler replaces the class with the wrapped value at runtime. Use @JvmInline for JVM compatibility.

Basic Value Class

@JvmInline
value class Email(val value: String) {
    init {
        require(value.contains("@")) { "Invalid email: $value" }
    }

    fun domain(): String = value.substringAfter("@")
}

val email = Email("alex@mail.com")
println(email.value)    // alex@mail.com
println(email.domain()) // mail.com

At runtime, this is just a String. No extra object is created.

Type Safety with Value Classes

Value classes prevent mixing up types that have the same underlying type:

@JvmInline
value class UserId(val value: Long) {
    init { require(value > 0) { "UserId must be positive" } }
}

@JvmInline
value class Password(val value: String) {
    init { require(value.length >= 8) { "Password must be at least 8 chars" } }

    val strength: String
        get() = when {
            value.length >= 16 -> "Strong"
            value.length >= 12 -> "Medium"
            else -> "Weak"
        }
}

@JvmInline
value class Meters(val value: Double) {
    fun toKilometers(): Double = value / 1000.0
    fun toCentimeters(): Double = value * 100.0
}

Usage:

fun findUser(id: UserId): String = "User #${id.value}"
fun sendEmail(to: Email, subject: String): String =
    "Email sent to ${to.value}: $subject"

val userId = UserId(42)
println(findUser(userId)) // User #42

val email = Email("alex@mail.com")
println(sendEmail(email, "Welcome!"))

Without value classes, you might accidentally pass a userId where an orderId is expected. Both are Long values, so the compiler would not catch the mistake. Value classes solve this problem.

Value Class Properties

Value classes can have computed properties but cannot store extra state:

val password = Password("mySecurePassword123")
println(password.strength) // Strong

val distance = Meters(1500.0)
println(distance.toKilometers()) // 1.5

Enum vs Sealed Class — Key Differences

Here is a detailed comparison:

FeatureEnumSealed Class
InstancesFixed at compile timeCreated at runtime
DataSame properties for allDifferent properties per subclass
HierarchyFlatHierarchical
entriesYesNo
valueOf()YesNo
OrdinalYesNo
SerializableAutomaticManual

The simplest way to decide: if every value has the same structure (like Direction.NORTH, Direction.SOUTH), use an enum. If different values need different data (like Success(data) vs Error(message, code)), use a sealed class.

Combining Sealed and Enum

You can use enums inside sealed classes:

enum class ErrorCode { NOT_FOUND, UNAUTHORIZED, SERVER_ERROR }

sealed class AppResult<out T> {
    data class Success<T>(val data: T) : AppResult<T>()
    data class Error(val code: ErrorCode, val message: String) : AppResult<Nothing>()
    data object Loading : AppResult<Nothing>()
}

fun handleError(error: AppResult.Error) {
    when (error.code) {
        ErrorCode.NOT_FOUND -> println("Not found: ${error.message}")
        ErrorCode.UNAUTHORIZED -> println("Auth error: ${error.message}")
        ErrorCode.SERVER_ERROR -> println("Server error: ${error.message}")
    }
}

This gives you the best of both worlds: type-safe error codes with flexible data.

Value Class Limitations

Value classes have some restrictions:

  1. Can only wrap a single value
  2. Can only have one val property in the primary constructor (init blocks are allowed for validation)
  3. Cannot participate in class hierarchies (no open, abstract, or sealed)
  4. Cannot have var properties (only val)
  5. Cannot use === identity checks reliably (the object may or may not exist at runtime)

Despite these limitations, value classes are very useful for domain modeling. They cost nothing at runtime but give you compile-time type safety.

When to Use Which

TypeUse when
Enum classFixed set of constants, all values known at compile time
Sealed classEach subclass holds different data, restricted hierarchy
Sealed interfaceLike sealed class but need multiple inheritance
Value classWrap a single value for type safety, zero overhead

Examples:

  • Direction (N, S, E, W) — Enum
  • LogLevel (DEBUG, INFO, ERROR) — Enum
  • UI state (Loading, Content, Error) — Sealed class
  • Network response (Success, Error) — Sealed class
  • Shape (Circle, Rectangle, Triangle) — Sealed interface
  • Email, UserId, Meters — Value class

Real-World Example: Form Validation

Here is a complete example using sealed classes and value classes together:

@JvmInline
value class EmailAddress(val value: String) {
    init { require(value.contains("@")) }
}

@JvmInline
value class Age(val value: Int) {
    init { require(value in 0..150) }
}

sealed class ValidationResult {
    data object Valid : ValidationResult()
    data class Invalid(val errors: List<String>) : ValidationResult()
}

fun validateRegistration(
    name: String,
    email: String,
    age: Int
): ValidationResult {
    val errors = mutableListOf<String>()

    if (name.isBlank()) errors.add("Name is required")

    runCatching { EmailAddress(email) }
        .onFailure { errors.add("Invalid email") }

    runCatching { Age(age) }
        .onFailure { errors.add("Age must be between 0 and 150") }

    return if (errors.isEmpty()) ValidationResult.Valid
    else ValidationResult.Invalid(errors)
}

when (val result = validateRegistration("Alex", "alex@mail.com", 25)) {
    is ValidationResult.Valid -> println("Registration successful!")
    is ValidationResult.Invalid -> {
        println("Errors:")
        result.errors.forEach { println("  - $it") }
    }
}

This example shows how sealed classes (for the result) and value classes (for validated types) work together in real code.

Source Code

You can find the complete source code for this tutorial on GitHub:

KT-12 Source Code on GitHub

What’s Next?

In this tutorial, you learned about enum classes, sealed classes, sealed interfaces, and value classes. These are essential tools for modeling your data in Kotlin.

In the next tutorial, you will learn about interfaces, generics, and type constraints. You will see how to write code that works with any type while keeping type safety.


This is part 12 of the Kotlin Tutorial series. Check out Part 11: Scope Functions if you missed it. Need a quick reference? See the Kotlin Cheat Sheet.