Stop Mixing Up Long IDs with Kotlin Inline Classes
Kotlin inline classes wrap a primitive in a distinct compile-time type without the runtime cost of a regular wrapper class. Here's when they help and where they break down.
20 Feb 2026, 23:20 UTC

The bug that compiles
Imagine a payment service with two functions. One accepts a user identifier, the other accepts an order identifier. Both identifiers are stored as Long. Because they share the same underlying type, Kotlin lets you pass a user ID where an order ID is expected. The code compiles, tests pass, and production charges the wrong record.
This is primitive obsession: using raw types such as Long or String for domain concepts instead of giving each concept its own type. A regular wrapper class fixes the type mix-up, but every UserId instance is an object on the JVM heap. In tight loops or high-throughput services, that allocation adds up.
Inline classes wrap a value without the wrapper object
Kotlin inline classes, declared with the value modifier, create a distinct compile-time type around exactly one underlying property. Stable on the JVM since Kotlin 1.5, they tell the compiler to treat UserId as its own type during type checking, but where possible, compile it down to the underlying Long at runtime.
value class UserId(val id: Long)
value class OrderId(val id: Long)
An inline class must have exactly one primary constructor property. It cannot declare initialization blocks, subclasses, or additional constructor properties. It can implement interfaces and expose computed properties.
Worked example: keep user IDs and order IDs apart
data class Payment(val orderId: OrderId, val amountCents: Long)
fun chargeOrder(orderId: OrderId, amountCents: Long) {
// charge the order
}
fun main() {
val userId = UserId(42L)
val orderId = OrderId(42L)
// Compile error: type mismatch. inferred type is UserId, but OrderId was expected
chargeOrder(userId, 1000L)
}
The compiler rejects chargeOrder(userId, 1000L) even though UserId and OrderId both wrap Long. The mistake is caught at build time instead of in production.
Check what the compiler actually emits
If runtime allocation matters, verify the bytecode rather than assuming the optimization applied. Run this in a terminal inside the directory that contains your compiled Kotlin class files:
javap -p -c <compiled-class-file>
Where to run: your project's build output directory, such as build/classes/kotlin/main. Required permissions: read access to the class files. Placeholder: replace <compiled-class-file> with the class that contains your inline class, for example PaymentKt.class. Expected check: method signatures and bodies use the underlying long type instead of constructing UserId objects in inlined contexts. Risk: output varies by Kotlin version, compiler flags, and whether the value crosses a generic or collection boundary, so treat the listing as evidence for that specific build, not a universal guarantee.
Limitations: when the benefit disappears
- Collections and generics force boxing. Storing
UserIdin aList<UserId>or passing it through a generic type typically boxes it into an object, removing the allocation advantage. - Identity and reflection can surprise you. Reference equality and runtime type checks may expose the underlying representation, so code that depends on object identity can behave unexpectedly.
- Modeling limits. Exactly one property, no initialization blocks, and no subclasses mean inline classes are unsuitable for complex domain objects. Use sealed classes or sealed interfaces when you need a fixed set of subtypes rather than a wrapped primitive.
A practical way to check for boxing is to place an instance in a List<Any> and inspect its runtime class. If you see a wrapper type instead of the primitive, that code path is allocating.
Use inline classes for identifiers, not everything
Inline classes fit well for domain IDs, monetary amounts, counts, and other single-value concepts where type safety matters more than object identity. Reach for regular classes or sealed types when you need multiple properties, inheritance, or heavy use in collections.
Start with one identifier type in your codebase, compile with Kotlin 1.5 or later, and inspect the bytecode with javap if allocation is a concern. The compiler catches the mix-ups; you decide whether the runtime representation matches your performance budget.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.