Kotlin Data Classes and Sealed Classes Explained
data class is Kotlin's killer feature for eliminating boilerplate — 1 line auto-generates equals/hashCode/toString/copy/componentN. sealed class builds finite type hierarchies that let the compiler enforce exhaustiveness — Charlie uses it to model the order state machine with zero missed cases.
1. What You'll Learn
- 6 auto-generated methods of
data class - Destructuring declarations:
val (id, total) = order sealed classwith exhaustivewhenbranches- Modeling state machines with sealed classes
- Charlie in action:
data class+sealed class
2. A Developer's Real Story
(1) Pain Point: Hand-Written equals Causing Bugs
Bob hand-wrote an equals method for the Java Order class but forgot to compare the status field. As a result, the Set deduplication treated orders with different statuses as the same order, causing 200 duplicate processing operations.
(2) data class Auto-Generated Solution
KOTLIN
// Java: 30 lines of error-prone equals/hashCode
// Kotlin: 1 line, compiler-generated and correct
data class Order(val id: String, val total: Double, val status: String)
// equals compares ALL constructor properties - can't forget a field
data classcompiler auto-generates equals/hashCode based on all constructor properties — impossible to miss a field.
3. data class Auto-Generation
(1) Basic Usage
KOTLIN
data class Order(val id: String, val total: Double, val status: String)
val o1 = Order("ORD-001", 299.99, "CONFIRMED")
val o2 = Order("ORD-001", 299.99, "CONFIRMED")
// Auto-generated equals: compares all constructor properties
println(o1 == o2) // true
// Auto-generated toString
println(o1) // Order(id=ORD-001, total=299.99, status=CONFIRMED)
// Auto-generated copy: create modified instance
val updated = o1.copy(status = "SHIPPED")
println(updated) // Order(id=ORD-001, total=299.99, status=SHIPPED)
// Auto-generated hashCode
println(o1.hashCode() == o2.hashCode()) // true
(2) 6 Auto-Generated Methods
| Method | Purpose | Based On |
|---|---|---|
equals() |
Value equality check | All constructor properties |
hashCode() |
Hash computation | All constructor properties |
toString() |
String representation | Order(id=..., total=..., status=...) |
copy() |
Create modified copy | Specified property new values |
component1() |
Destructuring declaration | By property declaration order |
componentN() |
Destructuring declaration | Property N → componentN |
(3) copy Deep Copy Warning
KOTLIN
data class Order(val id: String, val items: MutableList<String>)
val o1 = Order("ORD-001", mutableListOf("Widget", "Gadget"))
val o2 = o1.copy() // Shallow copy!
o2.items.add("Doohickey")
println(o1.items) // [Widget, Gadget, Doohickey] - shared reference!
// Fix: explicitly copy the list
val o3 = o1.copy(items = o1.items.toMutableList())
⚠️ Note:
copy() is a shallow copy — reference-type properties still share the same object. You must handle deep copying manually when needed.
4. Destructuring Declarations
(1) Basic Destructuring
KOTLIN
data class Order(val id: String, val total: Double, val status: String)
val order = Order("ORD-001", 299.99, "CONFIRMED")
// Destructuring declaration
val (id, total, status) = order
println("ID: $id, Total: \$$total USD, Status: $status")
// Partial destructuring with _
val (orderId, _, orderStatus) = order // Skip total
// In for-loop
val orders = listOf(order, Order("ORD-002", 1_500.00, "PENDING"))
for ((oid, ototal, ostatus) in orders) {
println("$oid: \$$ototal USD ($ostatus)")
}
(2) Destructuring Use Cases
KOTLIN
// Map entries
val statusMap = mapOf("ORD-001" to "SHIPPED", "ORD-002" to "PENDING")
for ((id, status) in statusMap) { ... }
// Function return values
data class Result(val success: Boolean, val message: String)
val (ok, msg) = processOrder()
// Lambda parameters
orders.map { (id, total) -> "Order $id: \$$total USD" }
5. sealed class
(1) Sealed Class Builds Finite Type Hierarchies
KOTLIN
// Sealed class: restricted class hierarchy
// All subclasses MUST be in the same file (Kotlin 1.5: same package)
sealed class OrderStatus {
object Pending : OrderStatus()
data class Processing(val step: Int, val totalSteps: Int) : OrderStatus()
object Shipped : OrderStatus()
data class Delivered(val deliveredAt: String) : OrderStatus()
data class Cancelled(val reason: String) : OrderStatus()
}
(2) Exhaustive when Branches
KOTLIN
fun handleStatus(status: OrderStatus): String = when (status) {
is OrderStatus.Pending -> "Waiting for payment"
is OrderStatus.Processing -> "Step ${status.step}/${status.totalSteps}"
is OrderStatus.Shipped -> "In transit"
is OrderStatus.Delivered -> "Delivered at ${status.deliveredAt}"
is OrderStatus.Cancelled -> "Cancelled: ${status.reason}"
// NO else needed - compiler verifies all cases covered
}
(3) Sealed Class State Machine
stateDiagram-v2
[*] --> Pending
Pending --> Processing : Pay
Pending --> Cancelled : Cancel
Processing --> Shipped : Ship
Processing --> Cancelled : Fail
Shipped --> Delivered : Deliver
Delivered --> [*]
Cancelled --> [*]
(4) sealed class vs enum class vs open class
| Dimension | enum class |
sealed class |
open class |
|---|---|---|---|
| Subclass count | Fixed enum values | Finite (known at compile time) | Infinite (runtime-extensible) |
| Subclass state | Singleton | Independent state each | Independent state each |
| when exhaustiveness | ✅ | ✅ | ❌ (requires else) |
| Subclass granularity | Same class | Different classes | Different classes |
| Extensibility | Non-extensible | Limited extension | Fully open |
| Use case | Simple enums | Finite types + state | Open inheritance |
6. Charlie in Action: Order Event Sourcing
KOTLIN
// Domain events for event sourcing
sealed class OrderEvent {
data class Created(val orderId: String, val total: Double, val customer: String) : OrderEvent()
data class PaymentReceived(val orderId: String, val amount: Double) : OrderEvent()
data class ItemAdded(val orderId: String, val sku: String, val qty: Int) : OrderEvent()
data class Shipped(val orderId: String, val trackingCode: String) : OrderEvent()
data class Cancelled(val orderId: String, val reason: String) : OrderEvent()
}
data class OrderState(val id: String, val total: Double, val status: String, val items: List<String>)
fun applyEvent(state: OrderState?, event: OrderEvent): OrderState = when (event) {
is OrderEvent.Created -> OrderState(event.orderId, event.total, "PENDING", emptyList())
is OrderEvent.PaymentReceived -> state!!.copy(status = "PAID")
is OrderEvent.ItemAdded -> state!!.copy(items = state.items + "${event.sku}x${event.qty}")
is OrderEvent.Shipped -> state!!.copy(status = "SHIPPED")
is OrderEvent.Cancelled -> state!!.copy(status = "CANCELLED")
}
7. Complete Example: OrderProcessor State Machine
KOTLIN
// ============================================
// OrderProcessor - State Machine with Sealed Classes
// Feature: Order lifecycle with exhaustive when
// ============================================
sealed class OrderStatus {
object Pending : OrderStatus()
data class Processing(val step: Int, val totalSteps: Int) : OrderStatus()
object Shipped : OrderStatus()
data class Delivered(val date: String) : OrderStatus()
data class Cancelled(val reason: String, val refundAmount: Double) : OrderStatus()
}
data class Order(val id: String, val total: Double, var status: OrderStatus = OrderStatus.Pending)
fun describeStatus(status: OrderStatus): String = when (status) {
is OrderStatus.Pending -> "Waiting for payment"
is OrderStatus.Processing -> "Processing step ${status.step}/${status.totalSteps}"
is OrderStatus.Shipped -> "In transit"
is OrderStatus.Delivered -> "Delivered on ${status.date}"
is OrderStatus.Cancelled -> "Cancelled: ${status.reason} (refund: \$${status.refundAmount} USD)"
}
fun canTransitionTo(current: OrderStatus, next: OrderStatus): Boolean = when {
current is OrderStatus.Pending && next is OrderStatus.Processing -> true
current is OrderStatus.Processing && next is OrderStatus.Shipped -> true
current is OrderStatus.Shipped && next is OrderStatus.Delivered -> true
current is OrderStatus.Pending && next is OrderStatus.Cancelled -> true
current is OrderStatus.Processing && next is OrderStatus.Cancelled -> true
else -> false
}
fun main() {
val orders = listOf(
Order("ORD-001", 299.99, OrderStatus.Pending),
Order("ORD-002", 1_500.00, OrderStatus.Processing(2, 5)),
Order("ORD-003", 8_900.00, OrderStatus.Shipped),
Order("ORD-004", 45.50, OrderStatus.Delivered("2026-01-15")),
Order("ORD-005", 2_500.00, OrderStatus.Cancelled("Customer request", 2_500.00))
)
println("=== Order Status Report ===")
orders.forEach { order ->
println("${order.id}: ${describeStatus(order.status)} (Total: \$${order.total} USD)")
}
// Test transitions
val testOrder = Order("ORD-099", 100.00)
println("\n=== Transition Tests ===")
println("Pending -> Processing(1,3): ${canTransitionTo(testOrder.status, OrderStatus.Processing(1, 3))}")
println("Pending -> Shipped: ${canTransitionTo(testOrder.status, OrderStatus.Shipped)}")
println("Pending -> Cancelled: ${canTransitionTo(testOrder.status, OrderStatus.Cancelled("Error", 100.0))}")
// Aggregate by status type
val statusCounts = orders.groupBy { it.status::class.simpleName }
println("\n=== Status Distribution ===")
statusCounts.forEach { (status, list) -> println(" $status: ${list.size} orders") }
}
Output:
TEXT
=== Order Status Report ===
ORD-001: Waiting for payment (Total: $299.99 USD)
ORD-002: Processing step 2/5 (Total: $1500.0 USD)
ORD-003: In transit (Total: $8900.0 USD)
ORD-004: Delivered on 2026-01-15 (Total: $45.5 USD)
ORD-005: Cancelled: Customer request (refund: $2500.0 USD) (Total: $2500.0 USD)
=== Transition Tests ===
Pending -> Processing(1,3): true
Pending -> Shipped: false
Pending -> Cancelled: true
=== Status Distribution ===
Pending: 1 orders
Processing: 1 orders
Shipped: 1 orders
Delivered: 1 orders
Cancelled: 1 orders
❓ FAQ
Q Can a data class inherit from other classes?
A It can inherit from non-data classes but cannot inherit from another data class. Data classes can implement interfaces.
Q Is data class copy a deep copy?
A No.
copy() is a shallow copy — reference-type properties still share the same object. Deep copying must be handled manually.Q Must sealed class subclasses be in the same file?
A Before Kotlin 1.5, they had to be in the same file. From 1.5+, they only need to be in the same package. This allows greater organizational flexibility.
Q What's the difference between sealed interface and sealed class?
A sealed interface (Kotlin 1.5+) allows multiple implementations, while sealed class only supports single inheritance. sealed interface is more flexible.
Q Why can sealed classes omit else in when?
A The compiler knows all subclasses of a sealed class, so it can verify whether all cases are covered in
when. Adding a new subclass will cause a compile error, reminding you to add the branch.Q Must all primary constructor parameters in a data class be val/var?
A No, but only val/var properties participate in equals/hashCode/copy/toString. Regular parameters do not.
📖 Summary
data classauto-generates equals/hashCode/toString/copy/componentN — 1 line replaces 60+ linescopy()is a shallow copy — reference-type properties still share objects- Destructuring declarations
val (a, b) = objare based on auto-generated componentN sealed classbuilds finite type hierarchies — compiler enforceswhenexhaustiveness- Sealed classes are ideal for modeling state machines, event types, and result types
sealed class>enum: subclasses can each hold different state data
📝 Exercises
- Beginner (⭐): Use
data classto defineProductand testcopy(),equals(), and destructuring declarations. Hint:data class Product(val name: String, val price: Double) - Intermediate (⭐⭐): Use
sealed classto modelResultasSuccess/Failure, and writehandleResultto handle both cases. Hint:sealed class Result { data class Success(...) ... } - Challenge (⭐⭐⭐): Model a complete order state machine using sealed classes (Pending→Processing→Shipped→Delivered / Cancelled), implement
transition()that throws on illegal state transitions. Hint:whenexhaustive checking +require()



