Kotlin Inline Value Classes: Zero-Cost Type Safety
The cheapest way to prevent a whole category of bugs in Kotlin is to stop passing raw String and Long around your domain and wrap them in value classes — and the reason you can do it everywhere without guilt is that a value class usually compiles down to the primitive it wraps, so the type safety costs you nothing at runtime. I've shipped payment code where a userId and an accountId were both Long, and swapping them in an argument list was a compile-clean disaster waiting for production. Value classes make that swap a compiler error while keeping the generated bytecode identical to the raw-Long version.
That "identical bytecode" claim is the whole point, and it's also where the nuance lives. The inlining is real, but it's conditional, and knowing exactly when the wrapper survives at runtime is the difference between a genuine zero-cost abstraction and an accidental allocation on your hottest loop.
What the compiler actually does
Declare a value class with @JvmInline and it wraps exactly one property:
@JvmInline
value class UserId(val raw: Long)
fun load(id: UserId): User { /* ... */ }
When you call load(UserId(42)), the compiler doesn't allocate a UserId object. It passes the long 42 directly, and load is compiled to take a long. The UserId type exists only in the type checker's head. There's no object header, no field indirection, no garbage for the collector to sweep. This is what people mean when they call value classes a "zero-cost abstraction" — the safety is a compile-time fiction that evaporates in the bytecode.
To avoid platform-signature clashes (two functions that both take a value class wrapping Long would have the same JVM signature), the compiler mangles method names with a hash suffix. You'll see this if you decompile or call from Java — the function isn't named load in bytecode, it's load-<hash>. That's harmless from Kotlin but worth knowing before you expose value classes across a Java boundary.
Where boxing sneaks back in
The unboxing only holds when the value class is used as itself, directly. The moment it has to behave like an object reference, the compiler boxes it — allocating the wrapper you thought you'd eliminated. The triggers:
- Nullable use.
UserId?can't be a rawlong, becauselonghas no null. SoUserId?is boxed. - Generics.
List<UserId>,Map<UserId, User>,Flow<UserId>— any generic type argument is boxed, because generics on the JVM erase toObject. - Assigned to a supertype. If your value class implements an interface and you use it through that interface, it boxes.
Anyor platform code. Passing it somewhere typedAny?, or into reflection, boxes it.
fun direct(id: UserId) {} // unboxed — passes a long
fun nullable(id: UserId?) {} // boxed — allocates
val ids: List<UserId> = listOf(UserId(1)) // boxed — generic arg
In practice this means value classes are a clean win for function parameters, return types, and local variables on hot paths, and a wash (same as a data class) the moment they land in a collection or a Flow. If you're wrapping IDs that spend their life inside List and Map, you're not getting the allocation benefit — you're getting the type safety, which is still worth it, just don't tell yourself it's free.
The type-safety payoff, concretely
The performance story is nice, but the reason I reach for value classes is correctness. Consider a function signature that used to be a minefield:
// Before: four Longs, any two swappable without a compiler complaint
fun transfer(from: Long, to: Long, amount: Long, requestedBy: Long)
// After: swapping arguments is a compile error
fun transfer(from: AccountId, to: AccountId, amount: Cents, requestedBy: UserId)
You can no longer pass a UserId where an AccountId belongs, or put the amount in the account slot. Whole classes of transposition bugs — the ones that pass every unit test because the types line up — become impossible. This is the same discipline as modeling domains with sealed interfaces: push invariants into the type system so the compiler enforces them for free.
You can also attach validation and behavior:
@JvmInline
value class Email(val value: String) {
init {
require("@" in value) { "invalid email: $value" }
}
val domain: String get() = value.substringAfter('@')
}
The init block runs on construction, so an invalid Email can't exist. Note the trade-off: that require check runs every time you construct one, including after unboxing, so it's not literally free — but it's the cheapest possible place to enforce the invariant, and infinitely cheaper than the bug.
Constraints you'll hit
Value classes are deliberately narrow, and the limits matter:
| Limitation | Consequence |
|---|---|
| Exactly one backing property | Can't wrap a pair; use a data class for multi-field |
Immutable (val only) |
No mutable wrappers |
No init-time this escape, no backing fields on other props |
Computed properties only |
| Can implement interfaces, but that boxes | Type safety survives, zero-cost doesn't |
No equals/hashCode override before recent versions |
Identity is the wrapped value's |
The single-property rule is the one people trip on. If you find yourself wanting two fields, you want a data class, and you should stop fighting it. Value classes are for the "this is really just a Long, but a meaningful Long" case.
Where they actually pay off in production
The pattern that's earned its keep for me: wrap every identifier and every unit-bearing primitive at the domain boundary. IDs (UserId, OrderId, DeviceId), money (Cents, Millis), and anything with a unit (Meters, Bytes). These are exactly the values that are semantically distinct but structurally identical, which is precisely the situation where a raw-primitive API lets you shoot yourself.
On an EV telemetry pipeline I worked on — related to how I architected the charging platform — meter readings, session IDs, and connector IDs were all Long. Wrapping them in value classes caught two argument-order bugs during the migration itself, before anything shipped. The readings stayed unboxed through the parsing hot path, so the per-message cost didn't move. That's the sweet spot: safety on the boundary, zero cost on the loop.
My rule of thumb: reach for value classes when a primitive has meaning beyond its representation and lives mostly on direct call paths. Skip them when the value spends its life inside generic collections — you'll get the safety but pay the allocation, so weigh it like any other data class. And never assume the abstraction is free without checking whether your usage boxes; the compiler will happily allocate behind your back, and only the decompiler tells the truth.
Resources
- Inline value classes — Kotlin documentation
- KEEP: inline classes design note
- Kotlin type system reference
- Effective Kotlin — item on domain-specific types
Frequently asked questions
What is a Kotlin value class?
A value class wraps a single underlying value and, in most cases, is compiled away so the wrapper never exists at runtime. You get a distinct type at compile time — a UserId that can't be confused with an OrderId — while the JVM sees a plain Long. It's declared with the value keyword and the @JvmInline annotation.
When does a Kotlin value class get boxed?
The compiler boxes a value class whenever it's used where an object reference is required: as a nullable type, as a generic type argument, when stored in a collection, or when passed to code that expects the supertype or Any. In those cases the wrapper object is actually allocated, so the zero-cost property only holds on the direct, non-nullable, non-generic paths.
Are value classes faster than data classes?
For single-field wrappers, yes, on the hot paths where they stay unboxed — there's no allocation and no indirection, so you pay nothing over the raw primitive. But value classes are limited to one backing property and can't be mutable, so they solve a narrower problem than data classes rather than replacing them.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →