The Problem with Primitive Obsession in Kotlin
In software development, we often fall into the trap of "primitive obsession." This happens when we use basic types like Int, String, or Long to represent complex domain concepts. For example, if you have a function that takes a userId and a productId, both as integers, it is incredibly easy to swap them by mistake. The compiler won't complain because an Int is an Int.
While you could wrap these in standard data classes to enforce type safety, doing so introduces a performance penalty. Every instance of a data class requires a heap allocation, which can add up quickly in high-performance loops or large collections. This is where Kotlin's value classes (introduced as inline classes) provide a elegant solution.
Defining a Value Class
A value class allows you to wrap a primitive or another type in a domain-specific wrapper that the compiler treats as a distinct type, but the runtime treats as the underlying primitive. To define one, use the value modifier and the @JvmInline annotation for JVM targets.
@JvmInline
value class UserId(val id: Int)
@JvmInline
value class ProductId(val id: Int)
// Usage
fun fetchProductDetails(user: UserId, product: ProductId) {
println("Fetching product ${product.id} for user ${user.id}")
}
val myUser = UserId(101)
val myProduct = ProductId(5005)
// This will compile
fetchProductDetails(myUser, myProduct)
// This will fail at compile-time, preventing a bug
// fetchProductDetails(myProduct, myUser)
How It Works Under the Hood
During compilation, Kotlin replaces the UserId and ProductId instances with the raw Int values wherever possible. This process is called "inlining." You get the developer experience of a strongly typed system—autocompletion, type checking, and readability—without the memory overhead of object allocation. It is effectively "free" type safety.
Key Constraints and Rules
Because value classes are designed to be lightweight wrappers, they come with a few restrictions you should keep in mind:
- Single Property: A value class must have exactly one primary constructor property.
- No Identity: You cannot use identity checks (
===) on value classes because they may be boxed or unboxed by the compiler. - No Inheritance: Value classes cannot extend other classes, though they can implement interfaces.
- Initialization: They cannot have
initblocks that contain complex logic; their purpose is purely to wrap a value.
Practical Use Case: Representing Units
Value classes are perfect for representing units of measurement, preventing accidental calculations between incompatible units like seconds and milliseconds.
@JvmInline
value class DurationMs(val millis: Long) {
val seconds: Long get() = millis / 1000
}
fun scheduleTask(delay: DurationMs) {
Thread.sleep(delay.millis)
}
val timeout = DurationMs(5000)
scheduleTask(timeout)
By using value classes, you make your intentions clear to other developers. You ensure that a function expecting milliseconds cannot be passed a raw value representing seconds, all while maintaining the performance of a raw Long.