Free Handbook · Every example compiled & verified

Data, Sealed & Enum Classes

Data classes, copy and destructuring, enum classes with behaviour, sealed hierarchies with exhaustive when, value classes and objects for modeling state.

0 / 136 lessons🔥 0 day streak
ShareXLinkedIn

Module 08 · what you'll be able to do

  • Write data classes and explain what equals, hashCode, toString, copy and componentN give you for free
  • Model a closed set of constants with an enum class that carries properties and methods
  • Build a sealed hierarchy and let an exhaustive when expression catch every missing case at compile time
  • Wrap a primitive in a zero-cost value class so an Email can never be passed where a UserId is expected
  • Model UI state and operation results as sealed types instead of nullable flags
01

Data classes: equality, hashing and printing for free

Most classes in a real codebase exist only to hold values: a user, an order line, a coordinate. In Java you would hand-write equals, hashCode and toString for each one. In Kotlin you put the word data in front of the class and the compiler generates all three from the properties declared in the primary constructor.

kotlinMain.kt
class PlainPoint(val x: Int, val y: Int)
data class Point(val x: Int, val y: Int)

fun main() {
    val a = PlainPoint(1, 2)
    val b = PlainPoint(1, 2)
    println(a == b)          // compares references

    val p = Point(1, 2)
    val q = Point(1, 2)
    println(p == q)          // compares x and y
    println(p)               // generated toString
    println(p.hashCode() == q.hashCode())
    println(setOf(p, q).size)
}
Outputcompiled & run with real Kotlin
false
true
Point(x=1, y=2)
true
1

Same fields, different class kinds: only the data class treats equal values as equal.

Your turn

Remove data from Point and predict all four lines before running it.

Only constructor properties count
A property declared in the class body is ignored by the generated equals, hashCode and toString. Two User objects with the same constructor values are equal even if a body property such as var loginCount differs. Put everything that defines identity in the constructor.
kotlinMain.kt
data class User(val name: String) {
    var loginCount = 0
}

fun main() {
    val a = User("Ada")
    val b = User("Ada")
    a.loginCount = 5
    println(a == b)
    println(a)
}
Outputcompiled & run with real Kotlin
true
User(name=Ada)
Error you will hit

A data class with no constructor properties

kotlin
data class Empty()

fun main() {
    println(Empty())
}
Main.kt:1:17: error: data class must have at least one primary constructor parameter.
data class Empty()
                ^^
Why the compiler said that

Everything a data class generates is built from its primary-constructor properties. With none, there is nothing to compare, hash or print, so the compiler refuses.

The fix

Give it at least one val/var parameter. If you really want a single value with nice printing, use data object instead (covered below).

kotlin
data object Empty

fun main() {
    println(Empty)
}
02

copy() and destructuring with componentN

Data classes are usually immutable (val everywhere). To "change" one you make a modified copy: copy() takes every property as a named argument with the current value as its default, so you only name what changes. The compiler also generates component1(), component2()… in constructor order, which is what makes destructuring work: val (name, age) = user.

kotlinMain.kt
data class Order(val id: Int, val item: String, val qty: Int, val paid: Boolean = false)

fun main() {
    val order = Order(7, "Keyboard", 1)
    val paid = order.copy(paid = true)
    println(order)
    println(paid)

    val (id, item, qty) = paid
    println("#$id: $qty x $item")

    val (_, name) = order     // skip a component with _
    println(name)
}
Outputcompiled & run with real Kotlin
Order(id=7, item=Keyboard, qty=1, paid=false)
Order(id=7, item=Keyboard, qty=1, paid=true)
#7: 1 x Keyboard
Keyboard
Your turn

Make a copy with qty = 3 and destructure it into (id, _, qty).

VisualizeWhat copy and destructuring actually callStep 1 / 5
data class Order(val id: Int, val item: String, val qty: Int)
val order = Order(7, "Keyboard", 1)
val bigger = order.copy(qty = 3)
val (id, item) = bigger
println("$id $item ${bigger.qty}")
Line 1

The compiler generates copy(id = this.id, item = this.item, qty = this.qty) plus component1() (id), component2() (item), component3() (qty).

Variables now

nothing yet

All 5 steps as a table
StepLineWhat happenedVariables now
11The compiler generates copy(id = this.id, item = this.item, qty = this.qty) plus component1() (id), component2() (item), component3() (qty).
22A new object is created.order = Order(7, Keyboard, 1)
33copy(qty = 3) builds a brand-new object. id and item fall back to the originals; order itself is untouched.order = Order(7, Keyboard, 1) bigger = Order(7, Keyboard, 3)
44Destructuring is sugar for val id = bigger.component1() and val item = bigger.component2(). Position matters, not the variable names.id = 7 item = Keyboard
55Prints the three values.

Destructuring is everywhere once you notice it: iterating a map gives you Map.Entry objects that have component1()/component2(), and Pair/Triple are just small generic data classes.

kotlinMain.kt
fun minMax(xs: List<Int>): Pair<Int, Int> = xs.min() to xs.max()

fun main() {
    val stock = mapOf("apples" to 4, "pears" to 0)
    for ((fruit, count) in stock) {
        println("$fruit -> $count")
    }
    val (lo, hi) = minMax(listOf(5, 2, 9))
    println("lo=$lo hi=$hi")
}
Outputcompiled & run with real Kotlin
apples -> 4
pears -> 0
lo=2 hi=9
Destructuring is positional — reorder with care
If a teammate reorders the constructor parameters of a data class, every val (a, b) = x in the codebase silently swaps meaning when the types match. For public data classes with many fields, prefer reading properties by name.
03

Enum classes with properties and methods

An enum class is a fixed list of named instances. Each constant can carry constructor values, and the class can have methods like any other. Every enum gets name, ordinal, entries (all constants, in declaration order) and valueOf(String) for free.

kotlinMain.kt
enum class Plan(val monthlyPrice: Int, val seats: Int) {
    FREE(0, 1),
    TEAM(12, 10),
    ENTERPRISE(40, 500);   // semicolon before members

    fun yearly(): Int = monthlyPrice * 12
    val isPaid: Boolean get() = monthlyPrice > 0
}

fun main() {
    for (p in Plan.entries) {
        println("${p.ordinal} ${p.name}: ${p.yearly()}/yr paid=${p.isPaid}")
    }
    val chosen = Plan.valueOf("TEAM")
    println(chosen.seats)
}
Outputcompiled & run with real Kotlin
0 FREE: 0/yr paid=false
1 TEAM: 144/yr paid=true
2 ENTERPRISE: 480/yr paid=true
10
Your turn

Add a STARTER(5, 3) constant between FREE and TEAM and watch every ordinal after it shift.

A constant can also override an abstract member with its own body, which is a clean way to attach per-case behaviour without a big when. And because the set of constants is closed, a when over an enum used as an expression must cover every constant — no else needed.

kotlinMain.kt
enum class Op(val symbol: String) {
    ADD("+") { override fun apply(a: Int, b: Int) = a + b },
    MUL("*") { override fun apply(a: Int, b: Int) = a * b };

    abstract fun apply(a: Int, b: Int): Int
}

enum class Light { RED, AMBER, GREEN }

fun next(l: Light): Light = when (l) {
    Light.RED -> Light.GREEN
    Light.GREEN -> Light.AMBER
    Light.AMBER -> Light.RED
}

fun main() {
    for (op in Op.entries) println("6 ${op.symbol} 7 = ${op.apply(6, 7)}")
    println(next(Light.RED))
}
Outputcompiled & run with real Kotlin
6 + 7 = 13
6 * 7 = 42
GREEN
Error you will hit

Trying to construct an enum

kotlin
enum class Level { LOW, HIGH }

fun main() {
    val l = Level()
    println(l)
}
Main.kt:4:13: error: cannot access 'constructor(): Level': it is private in 'Level'.
    val l = Level()
            ^^^^^
Main.kt:4:13: error: enum types cannot be instantiated.
    val l = Level()
            ^^^^^^^
Why the compiler said that

The whole point of an enum is that its instances are exactly the constants you listed. The constructor is private to the class itself.

The fix

Refer to a constant (Level.LOW) or parse one from text with Level.valueOf("LOW").

kotlin
enum class Level { LOW, HIGH }

fun main() {
    val l = Level.LOW
    println(l)
}
valueOf throws on unknown text
Plan.valueOf("team") throws IllegalArgumentException because names are case-sensitive. For user input use Plan.entries.firstOrNull { it.name.equals(input, ignoreCase = true) } and handle the null.
04

Sealed classes and interfaces with exhaustive when

An enum gives you a closed set of values. A sealed class or interface gives you a closed set of types: every direct subtype must be declared in the same package and module, so the compiler knows the full list. Each subtype can carry different data — something an enum cannot do. The payoff is when: the compiler checks that every subtype is handled, and a smart cast gives you the subtype's properties inside each branch.

kotlinMain.kt
sealed interface Shape
data class Circle(val r: Double) : Shape
data class Rect(val w: Double, val h: Double) : Shape
data object Dot : Shape

fun area(s: Shape): Double = when (s) {
    is Circle -> 3.0 * s.r * s.r      // s is smart-cast to Circle
    is Rect -> s.w * s.h
    Dot -> 0.0
}

fun main() {
    val shapes = listOf(Circle(2.0), Rect(3.0, 4.0), Dot)
    for (s in shapes) println("$s -> ${area(s)}")
}
Outputcompiled & run with real Kotlin
Circle(r=2.0) -> 12.0
Rect(w=3.0, h=4.0) -> 12.0
Dot -> 0.0
Your turn

Add data class Square(val side: Double) : Shape and compile. Read the error, then add the branch.

Error you will hit

A new subtype breaks every when that forgot it

kotlin
sealed interface Shape
data class Circle(val r: Double) : Shape
data class Square(val side: Double) : Shape
data class Triangle(val b: Double, val h: Double) : Shape

fun area(s: Shape): Double = when (s) {
    is Circle -> Math.PI * s.r * s.r
    is Square -> s.side * s.side
}

fun main() = println(area(Square(2.0)))
Main.kt:6:30: error: 'when' expression must be exhaustive. Add the 'is Triangle' branch or an 'else' branch.
fun area(s: Shape): Double = when (s) {
                             ^^^^
Why the compiler said that

Because Shape is sealed, the compiler knows Triangle exists and that this when would have no answer for it. This is the feature working: adding a case turns into a list of compile errors pointing at every place that needs updating.

The fix

Add the missing branch. Avoid else on sealed types — it silences exactly the check you want next time a subtype is added.

kotlin
fun area(s: Shape): Double = when (s) {
    is Circle -> Math.PI * s.r * s.r
    is Square -> s.side * s.side
    is Triangle -> 0.5 * s.b * s.h
}

enum class

  • Closed set of instances
  • Every case has the same properties
  • One object per constant
  • Gets entries, valueOf, ordinal

sealed class / interface

  • Closed set of subtypes
  • Each case has its own properties
  • Many instances per subtype (or an object)
  • No built-in list; use when with is
Sealed interface or sealed class?
Prefer sealed interface: subtypes can still extend another class, and a class can belong to two sealed hierarchies. Use sealed class when the cases share constructor state or implemented code.
05

object declarations, companion objects and data objects

object Name { … } declares a class and its single instance in one step. It is created lazily and thread-safely the first time it is used. A companion object lives inside a class and holds what Java would make static: factory functions and constants. A data object is a singleton with a readable toString, ideal for the no-data cases of a sealed hierarchy.

kotlinMain.kt
object Counter {
    private var n = 0
    fun next(): Int = ++n
}

class Temperature private constructor(val celsius: Double) {
    companion object {
        const val FREEZING = 0.0
        fun fromFahrenheit(f: Double) = Temperature((f - 32) * 5 / 9)
    }
}

object Plain
data object Loading

fun main() {
    Counter.next(); Counter.next()
    println(Counter.next())
    println(Temperature.fromFahrenheit(212.0).celsius)
    println(Temperature.FREEZING)
    println(Loading)
    println(Plain.toString().startsWith("Plain@"))
}
Outputcompiled & run with real Kotlin
3
100.0
0.0
Loading
true

A plain object prints as Plain@<hash>; a data object prints its name.

Singletons hold global state
An object with a var inside is global mutable state shared by every caller and every test. Use objects for stateless helpers and constants; pass real dependencies (a repository, an HTTP client) through constructors so tests can swap them.
06

Value classes: type safety at zero cost

A function fun transfer(from: String, to: String, amount: Long) will happily accept its arguments in the wrong order. Wrapping each primitive in its own type fixes that, but a normal wrapper costs an allocation per value. A value class — @JvmInline value class with exactly one val — is a distinct type to the compiler but is compiled down to the underlying value at runtime in most places.

kotlinMain.kt
@JvmInline
value class UserId(val raw: Long)

@JvmInline
value class Email(val raw: String) {
    init { require("@" in raw) { "not an email: $raw" } }
    val domain: String get() = raw.substringAfter("@")
}

fun sendWelcome(id: UserId, to: Email) = println("user ${id.raw} -> ${to.raw} (${to.domain})")

fun main() {
    sendWelcome(UserId(42), Email("[email protected]"))
    // sendWelcome(Email("[email protected]"), UserId(42))  // does not compile
    println(UserId(1) == UserId(1))
}
Outputcompiled & run with real Kotlin
user 42 -> [email protected] (example.com)
true
Your turn

Uncomment the swapped call and read the type mismatch the compiler gives you.

Error you will hit

Forgetting @JvmInline on the JVM

kotlin
value class Email(val raw: String)

fun main() = println(Email("[email protected]"))
Main.kt:1:1: error: value classes without '@JvmInline' annotation are not yet supported.
value class Email(val raw: String)
^^^^^
Why the compiler said that

On the JVM, value classes must be marked @JvmInline — the message says "not yet" because the annotation reserves plain value class for a future version where one value class could hold several fields.

The fix

Add @JvmInline on the line above.

kotlin
@JvmInline
value class Email(val raw: String)

fun main() = println(Email("[email protected]"))
07

Modeling UI state and results with sealed types

The most common beginner design for a screen is a pile of flags: isLoading: Boolean, error: String?, data: List<Item>?. That allows impossible combinations — loading and an error and data. A sealed type allows exactly the states that can exist, and each carries only the data it needs. This is the pattern Android and Jetpack Compose apps use for every screen.

kotlinMain.kt
sealed interface UiState {
    data object Loading : UiState
    data class Success(val items: List<String>) : UiState
    data class Error(val message: String, val canRetry: Boolean) : UiState
}

fun render(state: UiState): String = when (state) {
    UiState.Loading -> "[spinner]"
    is UiState.Success ->
        if (state.items.isEmpty()) "Nothing here yet" else state.items.joinToString(", ")
    is UiState.Error -> "Error: ${state.message}" + if (state.canRetry) " [Retry]" else ""
}

fun main() {
    val states = listOf(
        UiState.Loading,
        UiState.Success(listOf("Milk", "Eggs")),
        UiState.Success(emptyList()),
        UiState.Error("offline", canRetry = true),
    )
    states.forEach { println(render(it)) }
}
Outputcompiled & run with real Kotlin
[spinner]
Milk, Eggs
Nothing here yet
Error: offline [Retry]

The same idea models the outcome of an operation that can fail in expected ways. Kotlin ships Result<T>, but your own sealed type lets you name each failure so callers must handle it.

kotlinMain.kt
sealed interface ParseResult {
    data class Ok(val value: Int) : ParseResult
    data class Invalid(val input: String) : ParseResult
    data object Blank : ParseResult
}

fun parseAge(s: String): ParseResult {
    if (s.isBlank()) return ParseResult.Blank
    val n = s.trim().toIntOrNull() ?: return ParseResult.Invalid(s)
    return ParseResult.Ok(n)
}

fun main() {
    for (input in listOf("42", "forty", "  ")) {
        val msg = when (val r = parseAge(input)) {
            is ParseResult.Ok -> "age ${r.value}"
            is ParseResult.Invalid -> "'${r.input}' is not a number"
            ParseResult.Blank -> "please type something"
        }
        println(msg)
    }
}
Outputcompiled & run with real Kotlin
age 42
'forty' is not a number
please type something
Your turn

Add a data class TooOld(val value: Int) case for ages over 150 and let the compiler walk you to the when that needs it.

data class
A class whose equals, hashCode, toString, copy and componentN functions are generated from its primary-constructor properties.
copy()
Generated function that returns a new instance with some properties changed by name and the rest kept.
destructuring declaration
val (a, b) = x — calls x.component1(), x.component2() by position.
enum class
A type with a fixed list of named instances; each may carry properties and override methods.
sealed class / interface
A type whose direct subtypes are all known at compile time, which makes when checks exhaustive.
exhaustive when
A when expression that covers every possible case, so no else branch is needed.
object declaration
Declares a class and its single, lazily created instance.
companion object
An object nested in a class whose members are called through the class name, like Java statics.
value class
@JvmInline value class wrapping one value: a separate type at compile time, usually the raw value at runtime.
Quick check

You add a new subtype to a sealed interface. Every when expression over it that uses else will…

Quick check

What does println(User("Ada") == User("Ada")) print if User is a regular (non-data) class?

Frequently asked questions

When should I use a data class in Kotlin?
Whenever a class exists mainly to hold values that define it — DTOs, API responses, database rows, UI models, map keys. Do not use one for classes with identity and behaviour (a service, a connection), where two instances with the same fields are still different things.
What is the difference between a sealed class and an enum class in Kotlin?
An enum is a fixed set of instances that all share the same shape. A sealed class or interface is a fixed set of subtypes, each of which can carry different data and have many instances. Both make when expressions exhaustive.
Are Kotlin value classes really free?
Mostly. The wrapper disappears when the value is used as its own type, but it is boxed when used as a nullable type, a generic type argument (for example in a List) or through an interface. It is still cheaper and safer than passing raw strings and longs around.

Finish the Kotlin handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.