Dev.to · 12 min read

Zero-Cost Types? The Fine Print on Kotlin Value Classes

Zero-Cost Types? The Fine Print on Kotlin Value Classes

Compose’s own Dp, Color and TextUnit are value classes. Here's why. @JvmInline value class ShipmentId(val raw: String) reads like a free upgrade: a real type in the source, a plain String at runtime. Most of the time that is exactly what you get. Sometimes the compiler quietly allocates a wrapper object anyway, and sometimes the erasure itself causes trouble in Room, Retrofit, Java callers or your logs. This post walks through both halves so you can decide where value classes earn their keep. Compose layout and drawing code touches sizes and colors constantly, often many times per frame. Wrapping each one in a heap object would add up, so the toolkit wraps primitive-backed values instead and lets the compiler erase the wrapper. Dp wraps a Float and TextUnit wraps a Long. Color wraps a ULong, which is itself a value class that lowers to a plain long on the JVM. You can borrow the same trick in app code. 1. The primitive-obsession bug Here is a bug that survives code review more often than anyone admits: data class Tracking(val status: String) suspend fun trackShipment(shipmentId: String, courierId: String): Tracking = TODO() class TrackingViewModel(private val shipmentId: String, private val courierId: String) : ViewModel() { fun refresh() = viewModelScope.launch { val tracking = trackShipment(courierId, shipmentId) // arguments swapped, compiles fine } } Both parameters are String, so the compiler has nothing to object to. The backend returns a 404 or an empty page, and you spend an afternoon staring at logs. Typed wrappers turn the same mistake into a build error: @JvmInline value class ShipmentId(val raw: String) @JvmInline value class CourierId(val raw: String) suspend fun trackShipment(shipmentId: ShipmentId, courierId: CourierId): Tracking = TODO() class TrackingViewModel(private val shipmentId: ShipmentId, private val courierId: CourierId) : ViewModel() { fun refresh() = viewModelScope.launch { val tracking = trackShipment(courierId, shipmentId) // Type mismatch: does not compile } } A regular wrapper class (or a data class) would also fix this, but it costs an allocation per ID. Value classes aim to remove that cost. 2. Declaring one, and what the compiler generates @JvmInline value class ShipmentId(val raw: String) { init { require(raw.isNotBlank()) { "ShipmentId must not be blank" } } val isLegacy: Boolean get() = raw.startsWith("SH-") fun shortCode(): String = raw.takeLast(6) } The rules that matter on the JVM: The primary constructor takes exactly one val. The class is final and cannot extend another class, though it may implement interfaces. Other members must be computed. Anything with its own backing field is rejected. init blocks and secondary constructors with bodies are allowed on Kotlin 1.9 and later. The compiler does not simply delete the class. It emits a set of static helpers plus a wrapper that exists for the moments when a real object is unavoidable. To see it for yourself, open Tools > Kotlin > Show Kotlin Bytecode > Decompile on any file that uses the class, or run javap -p on the compiled .class. You will find constructor-impl, equals-impl0, box-impl and friends. Learn to recognize box-impl in decompiled output, because that call is the allocation you are trying to avoid. 3. Unboxed versus boxed The rule of thumb: when the static type at the use site is exactly the value class, the value stays raw. When the value must be treated as some other reference type, it gets wrapped. @JvmInline value class Meters(val value: Double) fun describe(distance: Meters): String = "${distance.value} m" // double in, no box fun parseDistance(text: String): Meters = Meters(text.toDouble()) // returns a raw double class Route(val length: Meters) // field is a plain double Now the cases where the wrapper sneaks back in: val ids: List = listOf(ShipmentId("SH-1")) // generics: each element boxed val anything: Any = ShipmentId("SH-2") // Any: boxed val maybeLength: Meters? = Meters(12.0) // nullable over a primitive: boxed val onClick: (ShipmentId) -> Unit = { println(it.raw) } // function type: boxed at invoke() Interfaces are the quiet one: interface Labeled { fun label(): String } @JvmInline value class Sku(val raw: String) : Labeled { override fun label(): String = raw } fun printLabel(item: Labeled) = println(item.label()) printLabel(Sku("A-100")) // Sku is boxed to satisfy the Labeled parameter The nullable subtlety A common claim is that “nullable always boxes”. It does not. It depends on what is wrapped: @JvmInline value class Email(val value: String) // wraps a non-null reference @JvmInline value class Meters(val value: Double) // wraps a primitive val a: Email? = null // stored as String?, no allocation val b: Meters? = null // needs a box, because a primitive double cannot hold null When the wrapped type is a non-null reference, null can reuse the underlying reference's own null. Primitives have no spare "null" bit pattern, so the wrapper object is required. This is why Compose exposes Dp.Unspecified, a sentinel built on NaN, instead of encouraging Dp? in hot paths. You can follow the same design: @JvmInline value class Meters(val value: Double) { val isSpecified: Boolean get() = !value.isNaN() companion object { val Unspecified = Meters(Double.NaN) } } Where boxing actually hurts In real Android code the culprits tend to be the boring ones: a StateFlow, a Map consulted in a loop, a LazyColumn key lambda returning a value class, or mutableStateOf(0.dp) updated every frame. Compose does ship primitive-specialized holders such as mutableFloatStateOf, but they take a raw Float, not a Dp, and there is no built-in Dp equivalent. Using one means unwrapping on write and re-wrapping on read, which gives up the type safety you adopted value classes for. If profiling shows the boxing matters, confine the conversion to one private boundary: private val offsetPx = mutableFloatStateOf(0f) val offset: Dp get() = offsetPx.floatValue.dp For everything else, measure with the Android Studio allocation profiler before reorganizing code. A few boxed IDs per screen are rarely the bottleneck, and a cleaner type is often worth the allocation. 4. Name mangling and Java interop Because ShipmentId and String are the same thing in a method signature, overloads like these would collide: class ShipmentRepository { fun load(id: ShipmentId): Shipment? = TODO() fun load(id: String): Shipment? = TODO() } To keep both legal, the compiler appends a hash derived from the signature to any function that takes a value class, so you end up with load-(String) and plain load(String). Consequences you will actually run into: Java callers cannot reference the mangled name. A hyphen is legal in bytecode but not in Java source. If a Java module must call the function, pin a readable name with @JvmName: @JvmName("loadShipment") fun load(id: ShipmentId): Shipment? = TODO() Java then passes a raw String, so the compile-time safety stops at the language boundary. Recent Kotlin releases have also been experimenting with an opt-in annotation for generating boxed-friendly entry points, so check the release notes for your version. Stack traces and logs show hashed names. Expect frames like load-3fK9a. It is cosmetic but confusing the first time. Reflection, mocking and name-based tooling need care. Anything that looks up methods by name, such as older mocking setups, ProGuard or R8 keep rules, and reflection-based DI, must account for the suffix. Run your test suite on the real tooling rather than assuming. Binary compatibility changes. If you publish a library, changing a parameter from String to a value class changes the JVM method name. Source-compatible, binary-incompatible. Constructors of classes with value-class parameters gain a synthetic marker parameter, which makes them awkward to call from Java. 5. Room, Retrofit and serialization Every library below sees the erased type in some places and the boxed type in others. That split causes most integration bugs. Room Value classes are supported in Room when you use KSP with Kotlin code generation. Under KAPT, the generated stubs only see the underlying type, so the processor cannot tell a value class from its wrapped type without reading metadata. Prefer KSP, and note that Room 3.0 is KSP-only anyway. @JvmInline value class CourierId(val raw: String) @JvmInline value class Grams(val value: Int) @Entity(tableName = "shipments") data class ShipmentEntity( @PrimaryKey val id: ShipmentId, val courier: CourierId?, val weight: Grams, ) @Dao interface ShipmentDao { @Query("SELECT * FROM shipments WHERE id = :id") suspend fun find(id: ShipmentId): ShipmentEntity? } Add a migration test that opens a database created before the change. The column type is the underlying type, so schemas should not shift, but verify it instead of trusting it. Retrofit A single @Path or @Query parameter of type ShipmentId reaches Retrofit as a String, because reflection sees the erased signature. The trap is collections: interface TrackingApi { @GET("shipments/{id}") suspend fun shipment(@Path("id") id: ShipmentId): ShipmentDto @GET("shipments") suspend fun shipments(@Query("id") ids: List): List } For the list, each element is boxed, and with no converter registered Retrofit falls back to toString(). The server receives ShipmentId(raw=SH-1). The same thing happens in string templates: "/shipments/$id" calls the generated toString, not raw. Register a string converter so every route behaves the same: object IdStringConverters : Converter.Factory() { override fun stringConverter( type: Type, annotations: Array, retrofit: Retrofit, ): Converter? = when (type) { ShipmentId::class.java -> Converter { it.raw } CourierId::class.java -> Converter { it.raw } else -> null } } Back this with a MockWebServer test that asserts the actual request URL. kotlinx.serialization This is the smoothest option. A @Serializable value class is encoded as its underlying value. Every value class that appears in a serializable model needs the annotation too, or the plugin fails the build: @Serializable @JvmInline value class ShipmentId(val raw: String) @Serializable @JvmInline value class CourierId(val raw: String) @Serializable data class ShipmentDto(val id: ShipmentId, val courier: CourierId? = null) // {"id":"SH-1"} not {"id":{"raw":"SH-1"}} The failure mode is validation. If init throws, a malformed payload throws during decoding. That is usually what you want, but handle it at the network boundary so one bad record does not crash the screen. Gson and other reflection-based codecs Reflection reads a field typed ShipmentId as String, but reads a List element as the wrapper class. The same ID can serialize as "SH-1" in one place and as an object in another. If you cannot move to a codegen or Kotlin-aware library, register explicit adapters and write round-trip tests. Check your Moshi setup the same way. 6. Where to use them Identifiers. The strongest case. IDs are passed everywhere, compared rarely, and swapped by accident constantly. Units. Dp-style types prevent seconds-versus-milliseconds and meters-versus-feet bugs. @JvmInline value class Millis(val value: Long) : Comparable { operator fun plus(other: Millis) = Millis(value + other.value) override fun compareTo(other: Millis): Int = value.compareTo(other.value) } val Int.seconds: Millis get() = Millis(this * 1_000L) Calling compareTo or < directly on Millis stays unboxed. Sorting a List boxes the elements, because the list is generic. Validated strings. Parse once at the edge, then carry a value that is known to be valid. @JvmInline value class Email private constructor(val value: String) { companion object { private val pattern = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") fun parseOrNull(raw: String): Email? { val cleaned = raw.trim().lowercase() return if (pattern.matches(cleaned)) Email(cleaned) else null } } } Email? wraps a non-null reference, so the nullable return costs nothing. Treat the private constructor as a compile-time guard only. Reflection and some decoders can still construct an invalid instance, so keep an init check if the invariant is critical. 7. Gotchas and limitations One property. Multi-field value classes are not available on the JVM today. Reach for a data class when you need two fields. No identity. === on a value class is rejected, and you should not rely on object identity for synchronization. No inheritance, no extra state. No superclass, no delegated properties, no stored var. Interfaces box. Implementing one is fine, but any use through the interface type allocates. Generics box. List, Map, Flow, State, Result wrappers and your own generic classes all hold wrappers rather than raw values. toString is the generated one. Logs, templates and URL building show ShipmentId(raw=...) unless you unwrap explicitly. Tooling surprises. Mangled names affect reflection, mocking, serialization and keep rules. Test with your real stack. Library ABI. Changing a public parameter to or from a value class breaks binary compatibility. Platform scope. @JvmInline is a JVM concern. Elsewhere in a multiplatform project, the same declaration compiles differently, so do not copy performance assumptions across targets. A quick decision checklist Is it one value with a domain meaning that is easy to mix up? Use a value class. Is it mostly passed as an argument or stored in a field? Great, it stays erased. Does it live in collections, flows or interface-typed code in a hot path? Measure first. Does it cross Java, Retrofit or a reflection-based codec? Add an explicit test at that boundary. Value classes are not literally free, but the costs are predictable once you know where the wrapper reappears. Use them for the swap-prone, validated, frequently passed values in your domain, and keep an eye on the generic and interface boundaries. 🧵 Stop Treating Coroutines Like Magic Keywords If “just add launch and hope” describes how your team handles concurrency, I’d recommend “Kotlin Coroutines Under the Hood” — a 25-chapter deep dive that goes past the syntax and down to the bytecode: the Continuation-Passing Style transform, the compiler-generated state machine, and exactly why a suspended coroutine costs a few dozen bytes while a blocked thread costs a megabyte. It walks through structured concurrency and Job hierarchies, dispatcher internals (Default vs IO vs Unconfined), cooperative cancellation and exception aggregation, Flow’s cold-stream semantics and the full operator vocabulary, StateFlow/SharedFlow’s hot-broadcast model, Channels and fan-in/fan-out patterns, Mutex/Semaphore/actors for shared state, and a complete Android case study retrofitting a legacy callback-driven app onto coroutines and Flow — including a 100,000-coroutines-vs-100,000-threads benchmark lab you can run yourself. E-book: Available on Google Play Kindle Edition: Available on Amazon Topmate: Available on Topmate Also available in Paperback & Hardcover. Originally published on Medium.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News