"Data, Sealed and Value" — Modelling the Domain
Two swapped String arguments make a Pune till ring up toor dal at zero. We rebuild the catalog model with data classes and copy(), destructuring, sealed hierarchies with exhaustive when, value classes that cost nothing until they box, and type aliases, and see what each compiles to.
Story Opening
The post-mortem for the Pune label incident was a fifteen-minute call. The mechanism was already known: the tills priced items by the code printed on the shelf label, found no product called 8901234567890, and fell back to zero. Kabir shared his screen to show the root cause, one call site and the function it called.
// Fragment of story/SwappedArguments.kt// The bug from Pune: two Strings with different meanings, swapped at the call site. It compiles.fun shelfLabel(sku: String, barcode: String, pricePaise: Long): String = "$sku [$barcode] ₹${pricePaise / 100}"
fun main() { val sku = "SHW-1001" val barcode = "8901234567890" println(shelfLabel(barcode, sku, 16_500)) // -> 8901234567890 [SHW-1001] ₹165}Kabir proposed the remediation he would have proposed at any Java shop: a review-checklist item, check argument order on label calls, and a unit test for every caller of shelfLabel.
Lena unmuted. “A checklist catches it when someone remembers to check. A type catches it on every build,” she said. “A SKU isn’t a string. Neither is a barcode, and a stock level isn’t an int with three magic values. What does it cost you to say so?”
In Java, the honest answer was quite a lot. This part is about why, in Kotlin, it isn’t.
Java → Kotlin: The Quick Map
| Java | Kotlin | Note |
|---|---|---|
record Price(long paise, String currency) | data class Price(val paise: Long, val currency: String) | Plus copy(), componentN(), and var if you insist |
new Price(p.paise() - 100, p.currency()) | p.copy(paise = p.paise - 100) | Named, partial copies |
record accessor price.paise() | price.paise | JavaBean getter getPaise() in bytecode |
Record pattern if (o instanceof Reading(var sku, var units, var aisle)) | val (sku, units, aisle) = reading | Both positional; Kotlin’s calls component1()… |
Singleton enum / static final instance | data object | With a readable toString() |
sealed interface … permits … | sealed interface | Subtypes in the same package and module, no permits list |
Pattern switch without default | when without else | Exhaustive; also via earlier returns (data-flow) |
case InStock s when s.units() > 100 | is InStock if level.units > 100 | Guard conditions |
Wrapper class final class Sku { String v; } | @JvmInline value class Sku(val value: String) | No allocation when used as its own type |
| (none) | typealias Index = Map<Sku, Product> | A second name, not a new type |
Conceptual Deep-Dive
Make the compiler hold the domain rules
Kabir’s bug wasn’t really a typo. It was a model that said string where the business meant SKU. Java developers usually patch that with conventions, such as parameter names, Javadoc and review checklists, because the alternative, a Sku wrapper class, costs an allocation per value and a page of equals/hashCode boilerplate.
Kotlin removes most of both costs, and that changes which design is cheapest. Three tools do most of the work:
- Data classes make a value type a one-line declaration, with correct
equals,hashCode,toStringand acopy()for immutable updates. Java 16 records closed much of this gap. The differences are in the details: Kotlin adds named partial copies, allows extra state in the body (records forbid instance fields), and can extend an open class (records can’t extend anything). - Sealed hierarchies describe a closed set of alternatives, such as the states of a shelf, and let the compiler prove that every
whenhandles all of them. Java 17 sealed types and Java 21 pattern matching have the same goal. Kotlin’s checker also follows data flow, so a case you have already returned from doesn’t need a branch. - Value classes give a primitive or a
Stringits own type, and in most places compile it back down to the raw value. Java has nothing like it today. Project Valhalla’s value classes (JEP 401, not yet a standard Java feature) remove identity so the JVM can flatten them, but they keep the wrapper type rather than erasing it at compile time.
The mental shift: in Java, a richer type is a cost you justify; in Kotlin, a missing type is.
When a value class is free and when it isn’t
A value class is a compile-time type wrapped around one runtime value. The compiler erases the wrapper wherever the static type is the value class itself, and must keep a real object wherever the JVM needs one:
| Used as | Sku (wraps String) | Paise (wraps Long) |
|---|---|---|
| Its own type: parameter, local, field, return | raw String | raw long |
Generic argument: List<Paise>, T | boxed Sku | boxed Paise |
Any or an interface it implements | boxed Sku | boxed Paise |
Nullable: Sku?, Paise? | raw String (nullable) | boxed Paise |
The last row is the surprise: Sku? is still a plain nullable String reference, while Paise? must become an object, because a primitive long can’t be null. The technical section shows the first two rows in bytecode.
Technical Explanation
Data classes and what they generate
data class Price(val paise: Long, val currency: String = "INR")
data class ProductLine(val sku: String, val name: String, val price: Price) { // Declared in the body, so not part of equals, hashCode, toString or copy. var lastAudit: String = "never"}
fun main() { val a = ProductLine("SHW-1001", "Toor Dal 1kg", Price(16_500)) val b = ProductLine("SHW-1001", "Toor Dal 1kg", Price(16_500))
println(a) // -> ProductLine(sku=SHW-1001, name=Toor Dal 1kg, price=Price(paise=16500, currency=INR)) println(a == b) // -> true println(setOf(a, b).size) // -> 1
// copy(): a new instance with some properties changed, by name. The original is untouched. val discounted = a.copy(price = a.price.copy(paise = 14_900)) println(discounted.price) // -> Price(paise=14900, currency=INR) println(a.price.paise) // -> 16500
b.lastAudit = "2026-10-01" println(a == b) // -> true
// Positional destructuring: component1(), component2(), in constructor order. val (sku, name) = a println("$sku / $name") // -> SHW-1001 / Toor Dal 1kg}javap shows everything the data keyword wrote for Price:
public final class com.shelfwise.part04.data.Price { private final long paise; private final java.lang.String currency; public Price(long, java.lang.String); public final long getPaise(); public final java.lang.String getCurrency(); public final long component1(); public final java.lang.String component2(); public final Price copy(long, java.lang.String); public static Price copy$default(Price, long, java.lang.String, int, java.lang.Object); public java.lang.String toString(); public int hashCode(); public boolean equals(java.lang.Object); ...}The rules:
- Only primary-constructor properties count.
equals,hashCode,toString,copyand thecomponentNfunctions use them and nothing else.lastAuditis invisible to equality, which is useful for caches and audit fields, and surprising if you didn’t intend it. (A Java record can’t hold such a field at all.) - The class is final, and can’t be
abstract,open,sealedorinner. It can implement interfaces and extend an open class. copy()is shallow.copy(price = …)shares every other property by reference, which is safe when those properties are immutable, as here.- Accessors are JavaBean getters (
getPaise()), not record-stylepaise().
Data classes are not always the right tool. They give a type value equality: two instances are equal when all their constructor properties are equal. That is right for Price and wrong for something with an identity. Part 3’s hands-on ended with two Products that shared a SKU but weren’t equal. As data classes, a product whose price changed is still not equal to its old self, because the price differs. The fix for identity is to key on the identity, a Map<Sku, Product>, not to bend equals. Part 13 explains why JPA entities, with their identity, lazy loading and mutability, should not be data classes at all.
copy() and private constructors
Validation in an init block is safe: copy() calls the primary constructor, so init runs again and Quantity(5).copy(units = -1) throws. A private constructor behind a factory is the other common pattern, needed when the factory normalises input, returns null or a cached instance, or validates something init can’t see. A data class leaks that constructor: copy() is public, so Discount.of(10).copy(percent = 500) skips the factory. Kotlin 2.4.20 warns on the declaration (non-public primary constructor is exposed via the generated 'copy()' method of the 'data' class.), adding that the generated copy() “will change its visibility in future releases”. It also warns at each call site that this “will become an error in language version 2.5”. Opt into the consistent behaviour now:
// Validation in init can't be bypassed: copy() calls the constructor, so init runs again.data class Quantity(val units: Int) { init { require(units >= 0) { "negative quantity: $units" } }}
// The constructor is private so that every Discount is validated. Without the annotation,// the generated copy() would stay public and let callers skip the check.@ConsistentCopyVisibilitydata class Discount private constructor(val percent: Int) { companion object { fun of(percent: Int): Discount { require(percent in 1..90) { "discount must be 1..90%, was $percent" } return Discount(percent) } }}
fun main() { try { Quantity(5).copy(units = -1) } catch (e: IllegalArgumentException) { println(e.message) // -> negative quantity: -1 }
val festive = Discount.of(10) println(festive) // -> Discount(percent=10) // festive.copy(percent = 500) // error: cannot access 'fun copy(percent: Int = ...): Discount': it is private in 'com.shelfwise.part04.data.Discount'.}@ConsistentCopyVisibility makes copy() as private as the constructor. The compiler flag -Xconsistent-data-class-copy-visibility does the same for every data class in a module. @ExposedCopyVisibility keeps copy() public, but the compiler marks it as discouraged, an escape hatch for binary compatibility only: source callers still get the warning, which becomes an error in 2.5, and only already-compiled callers keep working. (The range check in of() stands in for what a real factory does that init can’t, such as caching or normalising input.)
Destructuring: positional today, by name tomorrow
data class ShelfReading(val sku: String, val units: Int, val aisle: Int)
fun main() { val reading = ShelfReading("SHW-1001", 12, 7)
// Positional: the names on the left are yours; only the order matters. val (code, onShelf) = reading println("$code: $onShelf") // -> SHW-1001: 12 val (_, _, aisle) = reading // '_' skips a component without calling it println(aisle) // -> 7
// The trap: names that look meaningful are ignored. This compiles and swaps the values. val (units, sku) = reading println("units=$units sku=$sku") // -> units=SHW-1001 sku=12
// Destructuring in for loops and lambdas: Map.Entry and Pair have component1/component2 too. val stock = mapOf("SHW-1001" to 12, "SHW-1002" to 0) for ((item, count) in stock) println("$item=$count") // -> SHW-1001=12 // -> SHW-1002=0 println(stock.filter { (_, count) -> count == 0 }.keys) // -> [SHW-1002]
// Experimental in 2.4.20 — requires -Xname-based-destructuring=only-syntax. // Each variable is bound to a property by name, so order no longer matters. (val shelfUnits = units, val shelfSku = sku) = reading println("$shelfSku: $shelfUnits") // -> SHW-1001: 12}val (code, onShelf) = reading compiles to reading.component1() and reading.component2(), so names on the left carry no meaning at all. That is Kabir’s bug again, in a different place.
Name-based destructuring is Experimental in Kotlin 2.4.20 and needs a compiler flag. The companion module enables -Xname-based-destructuring=only-syntax in its own build.gradle.kts. In (val shelfUnits = units, val shelfSku = sku) = reading, units and sku name properties of reading, not the locals of the same name declared above. Without the flag, the compiler reports that the feature “is only available since language version 2.5”. The other modes show where the language is heading: complete moves positional destructuring to square brackets (val [x, y] = pair), and name-mismatch warns on every positional destructuring whose names don’t match the properties, saying such code “will change its meaning” in a future release. Treat name-mismatch as a migration-readiness report, and keep the feature out of production code until it is Stable.
Data objects
The stateless member of a sealed hierarchy is an object, and data object gives it a readable toString():
object PlainUnknownSupplier
data object UnknownSupplier // generated toString, equals and hashCode
fun main() { println(PlainUnknownSupplier) // -> com.shelfwise.part04.data.PlainUnknownSupplier@… println(UnknownSupplier) // -> UnknownSupplier}Its generated equals also holds if a serialisation library creates a second instance.
Sealed hierarchies and exhaustive when
// A closed set of states. Every subtype is known at compile time (same module, same package).sealed interface StockLevel { data class InStock(val units: Int) : StockLevel data class Low(val units: Int, val reorderAt: Int) : StockLevel data object OutOfStock : StockLevel data class Discontinued(val replacementSku: String?) : StockLevel}
// No else branch: the compiler checks that every subtype is handled.fun shelfBadge(level: StockLevel): String = when (level) { is StockLevel.InStock if level.units > 100 -> "BULK BUY" // guard condition is StockLevel.InStock -> "" is StockLevel.Low -> "LAST ${level.units}" StockLevel.OutOfStock -> "SOLD OUT" // an object is matched by equality, no 'is' needed is StockLevel.Discontinued -> if (level.replacementSku != null) "TRY ${level.replacementSku}" else "DISCONTINUED"}
// fun incomplete(level: StockLevel): String = when (level) { is StockLevel.InStock -> "ok"; is StockLevel.Low -> "low"; StockLevel.OutOfStock -> "out" }// error: 'when' expression must be exhaustive. Add the 'is Discontinued' branch or an 'else' branch.
// Data-flow exhaustiveness (Stable since 2.3.0): cases ruled out earlier don't need a branch.fun reorderNote(level: StockLevel): String { if (level is StockLevel.OutOfStock || level is StockLevel.Discontinued) return "no action" return when (level) { is StockLevel.InStock -> "fine" is StockLevel.Low -> "reorder at ${level.reorderAt}" }}
fun main() { val levels = listOf( StockLevel.InStock(240), StockLevel.InStock(30), StockLevel.Low(4, reorderAt = 10), StockLevel.OutOfStock, StockLevel.Discontinued(replacementSku = "SHW-2040"), ) for (level in levels) println("$level -> '${shelfBadge(level)}' / ${reorderNote(level)}") // -> InStock(units=240) -> 'BULK BUY' / fine // -> InStock(units=30) -> '' / fine // -> Low(units=4, reorderAt=10) -> 'LAST 4' / reorder at 10 // -> OutOfStock -> 'SOLD OUT' / no action // -> Discontinued(replacementSku=SHW-2040) -> 'TRY SHW-2040' / no action}What the compiler checks:
- No
permitslist. Every direct subtype must live in the same package and module as the sealed type, so the compiler can find them all itself. Nesting them inside the interface, as here, keeps the hierarchy in one place. - Exhaustiveness without
else.shelfBadgehas noelse, and that is the point: add a fifth state and everywhenthat forgot it stops compiling, with the commented-out error above. Anelsewould quietly absorb the new state. - Guards (
is InStock if level.units > 100, Stable since 2.2) refine a branch, but a guarded branch alone doesn’t count towards exhaustiveness: an unguarded branch for the same type (or anelse) must also cover it. - Data-flow exhaustiveness (Stable since 2.3) lets
reorderNotereturn early for two cases and handle the remaining two without anelse. Java’s pattern-matchingswitchdoesn’t take earlierifs into account.
sealed class works the same way and adds shared constructor state. Prefer sealed interface unless the subtypes genuinely share fields, because a class can implement several interfaces but extend only one class.
On the JVM, a Kotlin sealed type compiles to a JVM sealed type with a PermittedSubclasses attribute (for JVM targets 17 and up):
// javap -v com.shelfwise.part04.sealed.StockLevelPermittedSubclasses: com/shelfwise/part04/sealed/StockLevel$Discontinued com/shelfwise/part04/sealed/StockLevel$InStock com/shelfwise/part04/sealed/StockLevel$Low com/shelfwise/part04/sealed/StockLevel$OutOfStockSo Java code gets the exhaustiveness check too. This Java class in the same module compiles without a default branch, and javac rejects it if a case is removed. (Java checks at compile time only: a Java class compiled against an older version of the hierarchy throws MatchException at runtime when it meets a new subtype.)
static String badge(StockLevel level) { return switch (level) { case StockLevel.InStock in -> "in stock: " + in.getUnits(); case StockLevel.Low low -> "low: " + low.getUnits(); case StockLevel.OutOfStock out -> "sold out"; case StockLevel.Discontinued d -> "discontinued"; };}// badge(new StockLevel.Low(4, 10)) -> low: 4Value classes
// A value class wraps exactly one value. At runtime it is (usually) just that value.@JvmInlinevalue class Sku(val value: String) { init { require(value.matches(Regex("SHW-\\d{4}"))) { "bad SKU: $value" } }
override fun toString() = value}
@JvmInlinevalue class Barcode(val digits: String) { init { require(digits.length == 13 && digits.all { it.isDigit() }) { "bad EAN-13: $digits" } }}
@JvmInlinevalue class Paise(val amount: Long) { operator fun plus(other: Paise): Paise = Paise(amount + other.amount) // enables '+' (Part 6) fun format(): String = "₹${amount / 100}.${(amount % 100).toString().padStart(2, '0')}"}
fun shelfLabel(sku: Sku, barcode: Barcode, price: Paise): String = "$sku [${barcode.digits}] ${price.format()}"
fun basketTotal(prices: List<Paise>): Paise { var total = Paise(0) for (price in prices) total += price return total}
fun main() { val sku = Sku("SHW-1001") val barcode = Barcode("8901234567890") println(shelfLabel(sku, barcode, Paise(16_500))) // -> SHW-1001 [8901234567890] ₹165.00 // shelfLabel(barcode, sku, Paise(16_500)) // error: argument type mismatch: actual type is 'Barcode', but 'Sku' was expected.
try { Sku("1001") } catch (e: IllegalArgumentException) { println(e.message) // -> bad SKU: 1001 }
println(basketTotal(listOf(Paise(16_500), Paise(3_050))).format()) // -> ₹195.50 println(Sku("SHW-1001") == Sku("SHW-1001")) // -> true // Sku("SHW-1001") === Sku("SHW-1001") // error: identity equality for arguments of types 'Sku' and 'Sku' is prohibited.}The swapped call from the story is now a compile error, and every SKU constructed from Kotlin code has passed its init check. (Reflection-based frameworks can construct the boxed form without running init, a gap Part 13 comes back to.) The cost, in bytecode:
public final class com.shelfwise.part04.value.ValueClassesKt { public static final java.lang.String shelfLabel-6ccFuAM(java.lang.String, java.lang.String, long); public static final long basketTotal(java.util.List<com.shelfwise.part04.value.Paise>); ...}shelfLabel takes two raw Strings and a raw long, with no wrapper objects. basketTotal returns a raw long, but its List<Paise> parameter holds boxed Paise objects, because generics need objects. That is the first two rows of the deep-dive table, in bytecode.
Three consequences for Java developers:
- Name mangling.
shelfLabel-6ccFuAMexists so thatshelfLabel(Sku, …)and ashelfLabel(String, …)overload can’t clash after erasure. The-makes the name illegal in Java source, so Java cannot call functions that take value classes. Part 12 covers the options, including the Experimental@JvmExposeBoxed, which generates a callable boxed overload (it needs@OptIn(ExperimentalStdlibApi::class)in 2.4.20). - No identity.
===on value classes is a compile error, because two “instances” may be the same raw value or two boxes of it. Equality is always by the underlying value. - Exactly one property in the primary constructor, a
val. Other properties must be computed, andinitblocks run on every construction, which makes them the natural place for validation.
@JvmInline is required on the JVM, because Kotlin reserves room for future multi-field value classes when Valhalla arrives. Frameworks that work by reflection are the rough edge. Jackson needs the Kotlin module to (de)serialise value classes, Spring’s support for them in binding is partial and version-dependent, and JPA needs converters. Parts 12 and 13 show where that bites.
Type aliases
// A type alias is a second name for an existing type. It adds readability, not safety.typealias SkuCode = Stringtypealias PriceRule = (Long) -> Long
class Catalog(private val entries: Map<SkuCode, Long>) { // Nested type alias (Stable since 2.3.0): scoped to the class that gives it meaning. typealias Index = Map<SkuCode, Long>
fun index(): Index = entries}
fun applyRules(pricePaise: Long, rules: List<PriceRule>): Long { var result = pricePaise for (rule in rules) result = rule(result) return result}
fun main() { val code: SkuCode = "this is not a SKU at all" val plain: String = code // interchangeable with String, in both directions println(plain.length) // -> 24
val index: Catalog.Index = Catalog(mapOf("SHW-1001" to 16_500L)).index() println(index) // -> {SHW-1001=16500}
val festive: PriceRule = { it * 90 / 100 } val roundDown: PriceRule = { it / 100 * 100 } println(applyRules(16_550, listOf(festive, roundDown))) // -> 14800}A type alias is a second name, resolved away at compile time. It shortens long generic and function types (PriceRule is a function type, Part 5’s topic), but SkuCode accepts any string at all. When you want the compiler to tell a SKU from a barcode, use a value class. When you only want a shorter name, use a type alias. Since Kotlin 2.3.0 aliases can be nested inside a class (Catalog.Index), so they no longer pollute the package namespace.
Step-by-Step Hands-On: Rebuilding the Catalog Model
Code: kotlin-for-java-survivors/language/part04-data-sealed-and-value (file model/CatalogModel.kt).
Kabir rebuilds the catalog model so that the Pune bug can’t compile, and adds the event type that the services in Act III will publish to Kafka.
Step 1 — Identifiers and money as value classes. Sku is as before, and Barcode gains a toString() override so data-class output stays readable. Paise gains a check that prices can’t be negative:
// Fragment of model/CatalogModel.kt@JvmInlinevalue class Paise(val amount: Long) { init { require(amount >= 0) { "negative price: $amount" } }
override fun toString() = "₹${amount / 100}.${(amount % 100).toString().padStart(2, '0')}"}Step 2 — Stock as states, and the product as an immutable data class. Instead of the usual Java int stock plus boolean discontinued, discontinued is a state, so no flag can contradict it. Product is a data class because each instance is an immutable snapshot, where value equality is correct; the product’s identity lives in its Sku, the key of any index:
// Fragment of model/CatalogModel.kt// Step 2: stock as a closed set of states instead of an int plus flags.sealed interface StockLevel { data class InStock(val units: Int) : StockLevel data class Low(val units: Int) : StockLevel data object OutOfStock : StockLevel data class Discontinued(val reason: String) : StockLevel // no separate 'active' flag to contradict it}
// Step 2 (continued): the product itself is an immutable data class. Changes produce new instances.data class Product( val sku: Sku, val barcode: Barcode, val name: String, val price: Paise, val stock: StockLevel,)Step 3 — Everything that can happen, as a sealed hierarchy of events. Each event names its SKU through the shared interface property. (Serialising a sealed hierarchy to JSON needs a type discriminator; Part 14 sets that up for Kafka.)
// Fragment of model/CatalogModel.kt// Step 3: everything that can happen to a product, as a sealed hierarchy of events.sealed interface CatalogEvent { val sku: Sku
data class ProductAdded(val product: Product) : CatalogEvent { override val sku: Sku get() = product.sku }
data class PriceChanged(override val sku: Sku, val from: Paise, val to: Paise) : CatalogEvent data class StockChanged(override val sku: Sku, val level: StockLevel) : CatalogEvent data class Discontinued(override val sku: Sku, val reason: String) : CatalogEvent}Step 4 — Applying and describing events. Two exhaustive whens, with copy() doing every update. Add a Relabelled event next month and both functions stop compiling until someone decides what it means:
// Fragment of model/CatalogModel.kt// Step 4: applying an event is a when over the hierarchy, with copy() doing the update.fun applyEvent(current: Product?, event: CatalogEvent): Product = when (event) { is CatalogEvent.ProductAdded -> event.product is CatalogEvent.PriceChanged -> requireNotNull(current).copy(price = event.to) is CatalogEvent.StockChanged -> requireNotNull(current).copy(stock = event.level) is CatalogEvent.Discontinued -> requireNotNull(current).copy(stock = StockLevel.Discontinued(event.reason))}
fun describe(event: CatalogEvent): String = when (event) { is CatalogEvent.ProductAdded -> "added ${event.product.name}" is CatalogEvent.PriceChanged -> "price ${event.from} -> ${event.to}" is CatalogEvent.StockChanged -> "stock -> ${event.level}" is CatalogEvent.Discontinued -> "discontinued (${event.reason})"}Step 5 — Replay a product’s history.
// Fragment of model/CatalogModel.ktfun main() { val sku = Sku("SHW-1001") val events = listOf( CatalogEvent.ProductAdded( Product(sku, Barcode("8901234567890"), "Toor Dal 1kg", Paise(16_500), StockLevel.InStock(120)), ), CatalogEvent.PriceChanged(sku, from = Paise(16_500), to = Paise(15_900)), CatalogEvent.StockChanged(sku, StockLevel.Low(6)), CatalogEvent.Discontinued(sku, reason = "supplier change"), )
var product: Product? = null for (event in events) { product = applyEvent(product, event) println("${event.sku}: ${describe(event)}") } // -> SHW-1001: added Toor Dal 1kg // -> SHW-1001: price ₹165.00 -> ₹159.00 // -> SHW-1001: stock -> Low(units=6) // -> SHW-1001: discontinued (supplier change)
println(product) // -> Product(sku=SHW-1001, barcode=8901234567890, name=Toor Dal 1kg, price=₹159.00, stock=Discontinued(reason=supplier change))}A product’s state is now a fold over its events (written as a loop here; Part 7 has fold), and that is exactly how product-ai-service will rebuild its search index from Kafka in Part 15. The value classes’ toString() overrides keep the data class output readable: sku=SHW-1001 instead of sku=Sku(value=SHW-1001).
Tips, Tricks & Gotchas
Gotcha — arrays and
vars break data-class equality in different ways. AnArrayproperty compares by identity, because arrays don’t overrideequals(Part 2). Avarproperty used inhashCodelets a key change after it is in aHashMap, and the entry becomes unreachable:
data class Basket(val items: Array<String>) // arrays keep identity-based equals
data class StockKey(var sku: String) // a var in a data class used as a hash key
fun main() { println(Basket(arrayOf("rice")) == Basket(arrayOf("rice"))) // -> false
val key = StockKey("SHW-1001") val stock = hashMapOf(key to 12) key.sku = "SHW-9999" // the hashCode changes; the entry is now in the wrong bucket println(stock[key]) // -> null println(stock[StockKey("SHW-1001")]) // -> null println(stock.size) // -> 1}Use List instead of arrays in data classes, and val for every constructor property of anything you hash.
Gotcha — reordering a constructor silently changes every destructuring site. Positional destructuring follows
componentN()order, so swapping two same-typed constructor parameters recompiles cleanly and swaps values everywhere. DestructurePair,Map.Entryand small types whose order is their meaning; read wide data classes by name.
Gotcha — records and data classes look alike to Java, but aren’t. A data class’s accessors are
getPaise(), notpaise(), and it isn’t ajava.lang.Record, so Java record patterns (case Price(var p, var c)) don’t work on it. Part 12 shows@JvmRecordfor that.
Gotcha — the catalog index boxes every key. In a
Map<Sku, Product>, eachSkuis a generic argument, so every key is a realSkuobject wrapping aString: one more allocation per entry thanMap<String, Product>. Value classes are free in signatures, not in collections. For a hot, large index, measure before assuming the wrapper is free.
Key Takeaways
| Concept | Remember |
|---|---|
| Data classes | equals/hashCode/toString/copy/componentN from primary-constructor properties only |
copy() | Named and shallow; re-runs init; annotate (or use the flag) before 2.5, where an unannotated non-public constructor is an error |
| Destructuring | Positional by componentN(); name-based is Experimental (-Xname-based-destructuring) |
| Data objects | Stateless members of sealed hierarchies with a readable toString() |
| Sealed types | Closed hierarchies in one package and module; exhaustive when with no else, also via data flow |
| Guards | is T if condition ->, Java 21’s case T t when … |
| JVM view | Sealed types emit PermittedSubclasses: Java’s switch checks them too |
| Value classes | A distinct type, usually a raw value at runtime; boxed in generics, Any and nullable primitives |
| Java and value classes | Mangled names: not callable from Java without help |
| Type aliases | A second name for readability; no type safety |
Story Closing
Kabir merged the new model on Wednesday. The label code now took a Sku, a Barcode and a Paise, and the fix for the Pune incident was a compile error, which he pasted into the pull request description as proof that it could never happen again.
On Thursday he wrote the pricing rules: one rule object per discount, and an audit function that walked them with forEach, skipping rules that didn’t apply to the price. To skip one, he wrote return inside the lambda, exactly as he would in a Java forEach. The audit test failed: the log was empty. The very first rule that didn’t apply had returned from the whole function, and nothing after the loop ever ran.
Lena glanced at it. “That return doesn’t do what Java’s would,” she said. “In Kotlin, functions are values, and some of them are inlined.”
In Part 5, Kabir learns what a lambda really is in Kotlin, and where a return inside one actually goes.
This is Part 4 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”