01 March 2026
I’ve lost count of how many times I’ve seen functions with two or three Long parameters lined up next to each other. transfer(fromId: Long, toId: Long, amount: Long) looks perfectly reasonable until someone calls transfer(amount, fromId, toId) and silently moves money from the wrong account. The compiler won’t catch it because every argument is the same type. It compiles, it runs, and it does the wrong thing. In a production Android app handling real payments, this kind of bug doesn’t surface until a customer reports a broken transaction and you spend a full day tracing through logs.
The traditional fix is wrapping each parameter in a dedicated class — UserId, Amount, Currency. But that means heap allocations and extra garbage collection pressure for something that’s fundamentally just a Long at runtime. Kotlin’s value classes solve exactly this problem. They give you compile-time type safety with zero runtime overhead in most cases. The compiler treats UserId and Amount as distinct types during compilation, then erases the wrapper entirely and uses the raw primitive in the generated bytecode. You get the safety of a wrapper class with the performance of a primitive.
A value class is declared with the @JvmInline annotation and the value keyword. It wraps exactly one property, declared in the primary constructor. At compile time, the wrapper is a distinct type with full type checking. At runtime, the JVM sees only the underlying primitive or reference type — no wrapper object exists on the heap. The @JvmInline annotation is required on the JVM today because the Valhalla project, which would add native value types to the JVM, isn’t finished yet. Once Valhalla lands, the annotation may become unnecessary.
@JvmInline
value class UserId(val value: Long) {
init {
require(value > 0) { "UserId must be positive, got $value" }
}
}
@JvmInline
value class Email(val value: String) {
init {
require(value.contains("@")) { "Invalid email: $value" }
}
}
Now the transfer function becomes impossible to misuse:
fun transfer(from: UserId, to: UserId, amount: Long) {
// business logic
}
val fromUser = UserId(1001)
val toUser = UserId(2002)
transfer(fromUser, toUser, 5000) // compiles
transfer(5000, fromUser, toUser) // COMPILE ERROR: Long is not UserId
transfer(toUser, fromUser, 5000) // compiles but semantically correct types
The compiler now enforces that you can’t accidentally pass an Amount where a UserId is expected. The parameter-swapping bug that was invisible with raw Long parameters becomes a compile error.
Here’s where value classes get interesting. The Kotlin compiler performs type erasure on the wrapper — similar in spirit to how the JVM erases generic type parameters, but applied to the wrapper class itself. When you call findUser(UserId(123)), the compiled bytecode is effectively findUser-<hash>(123L). The UserId wrapper never gets allocated. No constructor call, no object on the heap, no garbage collection. Just the raw Long moving through the call stack.
The method name mangling deserves attention. The compiler appends a hash to function names that accept value classes — something like findUser-abc123. This prevents collisions when calling from Java code. If you had two functions process(UserId) and process(OrderId), both would erase to process(long) without mangling, creating an ambiguous overload. The hash suffix keeps them distinct in the bytecode. This is why value class functions aren’t directly callable from Java without the @JvmName annotation — the mangled names aren’t human-friendly.
But the zero-overhead promise has limits. Boxing — actually allocating the wrapper object on the heap — happens in four specific situations. When a value class is used as a generic type parameter, the JVM needs an Object reference because generics are erased to Object. When the value class appears as a nullable type, primitives like Long can’t represent null, so the wrapper must be boxed. When the value class is cast to an interface it implements, the runtime needs an actual object to dispatch the interface method. And when you use identity comparison (===), the runtime needs object references to compare. Understanding these four cases is the difference between getting zero overhead and accidentally allocating wrappers everywhere.
One of the strongest arguments for value classes over raw types is validation at the construction boundary. The init block runs every time a value class is created, which means invalid values never enter your system. And since the wrapper is erased at runtime, this validation doesn’t cost you an extra object allocation — you pay only for the validation check itself.
@JvmInline
value class Percentage(val value: Int) {
init {
require(value in 0..100) { "Percentage must be 0-100, got $value" }
}
}
@JvmInline
value class PositiveInt(val value: Int) {
init {
require(value > 0) { "Must be positive, got $value" }
}
}
@JvmInline
value class EmailAddress(val value: String) {
init {
require(value.matches(Regex("^[^@]+@[^@]+\\.[^@]+$"))) {
"Invalid email format: $value"
}
}
}
This pattern creates what I think of as a “parse, don’t validate” boundary — a concept from typed functional programming that fits naturally here. Once you have a Percentage instance, you know it’s between 0 and 100. Every function that accepts Percentage can skip range checks because the type itself guarantees validity. Compare this to passing raw Int everywhere and scattering require(value in 0..100) checks across your codebase — that approach is fragile, duplicated, and easy to forget. With a value class, validation happens exactly once, at the point of creation, and the type system carries that guarantee forward.
I said earlier that boxing happens in four cases. Let me show each one, because this is the tradeoff you need to internalize before using value classes extensively.
Generic type parameters force boxing because the JVM erases generics to Object:
fun <T> identity(value: T): T = value
val userId = UserId(42)
identity(userId) // boxed: T erases to Object, needs a reference
Nullable usage forces boxing because primitives can’t be null:
fun findUser(id: UserId): User? { /* ... */ }
var cachedId: UserId? = null // boxed: Long can't be null
cachedId = UserId(42) // boxing allocation here
Interface casts require a real object for method dispatch:
interface Printable {
fun display(): String
}
@JvmInline
value class OrderId(val value: Long) : Printable {
override fun display(): String = "Order-$value"
}
val printable: Printable = OrderId(99) // boxed: interface reference needed
Identity comparison needs object references:
val a = UserId(1)
val b = UserId(1)
println(a == b) // true, structural equality, no boxing
println(a === b) // undefined behavior for value classes, forces boxing
In practice, the first two cases come up most often. If you’re putting value classes into List<UserId> or Map<UserId, User>, they’ll be boxed because collections use generic types. For hot paths where this matters, consider using primitive-backed collections or restructuring your code to avoid generic containers for value-class-heavy data.
This comparison trips people up because all three look like they create “named types,” but they behave very differently at the type system and runtime level.
A value class wraps a single value, creates a genuinely distinct type at compile time, and erases to the underlying type at runtime. It has zero allocation overhead in most cases, and the compiler prevents you from mixing up UserId with OrderId even though both wrap Long.
A data class holds multiple properties, generates equals(), hashCode(), toString(), copy(), and destructuring functions. Every instance is a real object allocation on the heap. Use data classes when you need to group related properties together — data class User(val id: UserId, val name: String, val email: Email). They solve a completely different problem than value classes.
A type alias creates an alternative name for an existing type, but provides zero type safety. This is the dangerous one, and I’ve seen it bite teams who thought they were getting the same benefit as value classes:
typealias UserId = Long
typealias OrderId = Long
fun processUser(id: UserId) { /* ... */ }
fun processOrder(id: OrderId) { /* ... */ }
val userId: UserId = 42L
val orderId: OrderId = 99L
processUser(orderId) // COMPILES! No error. Silent bug.
processOrder(userId) // COMPILES! No error. Silent bug.
The compiler treats UserId and OrderId as identical to Long. There is no distinct type, no compile-time checking, no protection against parameter swapping. Type aliases are useful for readability — typealias ClickHandler = (View) -> Unit makes callback signatures more readable — but they should never be used when you need type safety. If you’re wrapping a single value and you need the compiler to enforce type distinctions, always use a value class, not a type alias.
I’d go as far as saying that using type aliases for domain identifiers is an anti-pattern. It gives a false sense of safety — you think your code is typed correctly because the names read well, but the compiler is doing nothing to enforce those names. Value classes cost you one line of additional code and give you actual compile-time guarantees.
Thanks for reading!
Explanation: Value classes are erased to their underlying type in most cases. Boxing occurs in four specific situations: nullable usage (Long can’t be null), generic type parameters (must be Object), interface casts, and identity (===) comparisons. Understanding these cases helps you avoid unexpected allocations.
Explanation:
typealias UserId = Longandtypealias OrderId = Longare interchangeable — the compiler treats them as the same type.@JvmInline value class UserId(val value: Long)creates a genuinely distinct type that cannot be confused with OrderId at compile time.
Create value classes for a type-safe API: UserId(Long), Email(String) with validation, Amount(Double) with non-negative validation, and Currency(String) with ISO code validation (3 uppercase letters). Then write a transferMoney(from: UserId, to: UserId, amount: Amount, currency: Currency) function that demonstrates how value classes prevent parameter swapping at compile time.
@JvmInline
value class UserId(val value: Long) {
init {
require(value > 0) { "UserId must be positive, got $value" }
}
}
@JvmInline
value class Email(val value: String) {
init {
require(value.matches(Regex("^[^@]+@[^@]+\\.[^@]+$"))) {
"Invalid email: $value"
}
}
}
@JvmInline
value class Amount(val value: Double) {
init {
require(value >= 0.0) { "Amount cannot be negative, got $value" }
}
}
@JvmInline
value class Currency(val code: String) {
init {
require(code.matches(Regex("^[A-Z]{3}$"))) {
"Currency must be 3 uppercase letters, got $code"
}
}
}
fun transferMoney(from: UserId, to: UserId, amount: Amount, currency: Currency) {
println("Transferring ${amount.value} ${currency.code} from ${from.value} to ${to.value}")
}
fun main() {
val sender = UserId(1001)
val receiver = UserId(2002)
val payment = Amount(250.00)
val usd = Currency("USD")
transferMoney(sender, receiver, payment, usd) // compiles
// transferMoney(payment, sender, receiver, usd) // COMPILE ERROR
// transferMoney(sender, usd, payment, receiver) // COMPILE ERROR
}