Kotlin Inline Classes: Type‑Safe IDs Without Runtime Overhead
Why Inline Classes?
If you have spent any time debugging a function that accidentally swapped a userId for an orderId, you know how painful primitive obsession can be. Kotlin’s inline classes give you a lightweight wrapper that the compiler erases at runtime, so you get compile‑time safety with zero allocation overhead.
The Problem: Primitive Obsession
Most codebases model identifiers as plain Long, String or UUID. The compiler happily lets you pass a userId where an orderId is expected because they share the same underlying type. Unit tests catch some of these bugs, but the feedback loop is slow and the error messages are cryptic.
Enter Inline Classes
An inline class is declared with the value modifier (or @JvmInline on older versions). The wrapper exists only at compile time; the JVM sees the underlying primitive directly. This means no extra object allocation, no indirection, and no performance penalty.
@JvmInline
value class UserId(val value: Long)
@JvmInline
value class OrderId(val value: Long)
Real‑World Example: Distinguishing IDs
Imagine a service that fetches a user’s recent orders. The signature should make it impossible to confuse the two identifiers.
interface OrderRepository {
fun findRecentOrders(userId: UserId, limit: Int = 10): List
}
class OrderService(private val repo: OrderRepository) {
fun getRecentOrders(userId: UserId): List =
repo.findRecentOrders(userId).map { OrderSummary(it.id, it.total) }
}
// Call site
val currentUser = UserId(42L)
val summaries = orderService.getRecentOrders(currentUser)
If a developer mistakenly writes orderService.getRecentOrders(OrderId(7L)), the compiler flags a type mismatch immediately. No runtime exception, no confusing stack trace.
Performance Notes
Because the wrapper is inlined, the generated bytecode for UserId is essentially a long. Benchmarks show identical throughput to passing raw primitives. The only cost is a tiny amount of metadata for the type system, which disappears after compilation.
Inline classes give you the safety of a custom type without the allocation tax of a regular class.
When to Reach for Them
- Domain identifiers (UserId, OrderId, CorrelationId)
- Units of measure (Meter, Second, Byte)
- Any primitive that carries semantic meaning beyond its raw value
Avoid inline classes when you need inheritance, multiple fields, or runtime polymorphism — those scenarios still require a full class.
Wrap‑Up
Adopting inline classes for IDs and other domain primitives has cut a whole class of bugs from my teams’ backlogs. The change is mechanical, the performance impact is nil, and the code reads like a specification. Next time you see a Long traveling through your API, ask yourself: does this value have a name? If yes, give it an inline class and let the compiler do the heavy lifting.