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

JavaKotlinNote
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.paiseJavaBean getter getPaise() in bytecode
Record pattern if (o instanceof Reading(var sku, var units, var aisle))val (sku, units, aisle) = readingBoth positional; Kotlin’s calls component1()…
Singleton enum / static final instancedata objectWith a readable toString()
sealed interface … permits …sealed interfaceSubtypes in the same package and module, no permits list
Pattern switch without defaultwhen without elseExhaustive; also via earlier returns (data-flow)
case InStock s when s.units() > 100is InStock if level.units > 100Guard 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, toString and a copy() 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 when handles 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 String its 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 asSku (wraps String)Paise (wraps Long)
Its own type: parameter, local, field, returnraw Stringraw long
Generic argument: List<Paise>, Tboxed Skuboxed Paise
Any or an interface it implementsboxed Skuboxed 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, copy and the componentN functions use them and nothing else. lastAudit is 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, sealed or inner. 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-style paise().

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.
@ConsistentCopyVisibility
data 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 permits list. 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. shelfBadge has no else, and that is the point: add a fifth state and every when that forgot it stops compiling, with the commented-out error above. An else would 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 an else) must also cover it.
  • Data-flow exhaustiveness (Stable since 2.3) lets reorderNote return early for two cases and handle the remaining two without an else. Java’s pattern-matching switch doesn’t take earlier ifs 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.StockLevel
PermittedSubclasses:
com/shelfwise/part04/sealed/StockLevel$Discontinued
com/shelfwise/part04/sealed/StockLevel$InStock
com/shelfwise/part04/sealed/StockLevel$Low
com/shelfwise/part04/sealed/StockLevel$OutOfStock

So 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.)

BadgeFromJava.java
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: 4

Value classes

// A value class wraps exactly one value. At runtime it is (usually) just that value.
@JvmInline
value class Sku(val value: String) {
init {
require(value.matches(Regex("SHW-\\d{4}"))) { "bad SKU: $value" }
}
override fun toString() = value
}
@JvmInline
value class Barcode(val digits: String) {
init {
require(digits.length == 13 && digits.all { it.isDigit() }) { "bad EAN-13: $digits" }
}
}
@JvmInline
value 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-6ccFuAM exists so that shelfLabel(Sku, …) and a shelfLabel(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, and init blocks 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 = String
typealias 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
@JvmInline
value 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.kt
fun 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. An Array property compares by identity, because arrays don’t override equals (Part 2). A var property used in hashCode lets a key change after it is in a HashMap, 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. Destructure Pair, Map.Entry and 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(), not paise(), and it isn’t a java.lang.Record, so Java record patterns (case Price(var p, var c)) don’t work on it. Part 12 shows @JvmRecord for that.

Gotcha — the catalog index boxes every key. In a Map<Sku, Product>, each Sku is a generic argument, so every key is a real Sku object wrapping a String: one more allocation per entry than Map<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

ConceptRemember
Data classesequals/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
DestructuringPositional by componentN(); name-based is Experimental (-Xname-based-destructuring)
Data objectsStateless members of sealed hierarchies with a readable toString()
Sealed typesClosed hierarchies in one package and module; exhaustive when with no else, also via data flow
Guardsis T if condition ->, Java 21’s case T t when …
JVM viewSealed types emit PermittedSubclasses: Java’s switch checks them too
Value classesA distinct type, usually a raw value at runtime; boxed in generics, Any and nullable primitives
Java and value classesMangled names: not callable from Java without help
Type aliasesA 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.”