Kotlin Value Classes & Inline Functions — Zero-Cost Abstractions
Learn how Kotlin value classes and inline functions give you type safety and abstraction without runtime overhead — wrapping primitives, type-safe IDs, and performance patterns.
You have a function that takes three Strings:
fun createUser(name: String, email: String, role: String) { /* ... */ }
Swap the arguments by accident:
createUser("admin", "Alice", "[email protected]") // compiles, wrong
The compiler can’t help — everything is a String. You want type safety, but wrapping every String in a class means heap allocation, garbage collection, boxing overhead.
Kotlin’s answer: value classes and inline functions. Type safety at compile time, zero overhead at runtime.
Value classes
A value class wraps a single value. At runtime, the wrapper is erased — the JVM sees the raw value:
@JvmInline
value class Email(val value: String)
@JvmInline
value class UserId(val value: String)
@JvmInline
value class Role(val value: String)
Now the function signature is type-safe:
fun createUser(name: String, email: Email, role: Role) { /* ... */ }
// This works
createUser("Alice", Email("[email protected]"), Role("admin"))
// This doesn't compile
createUser("Alice", Role("admin"), Email("[email protected]"))
// Type mismatch: Required Email, Found Role
At runtime, Email("[email protected]") is just "[email protected]". No object creation, no heap allocation, no garbage collection.
Rules
- Exactly one property in the primary constructor
- Must be annotated with
@JvmInline - Can have methods, properties, init blocks
- Cannot have backing fields
@JvmInline
value class Percentage(val value: Double) {
init {
require(value in 0.0..100.0) { "Percentage must be 0-100, got $value" }
}
fun asDecimal(): Double = value / 100.0
override fun toString(): String = "$value%"
}
val discount = Percentage(15.0)
println(discount) // 15.0%
println(discount.asDecimal()) // 0.15
Practical: Type-safe IDs
The most valuable use case — preventing ID mixups:
@JvmInline
value class OrderId(val value: String)
@JvmInline
value class CustomerId(val value: String)
@JvmInline
value class ProductId(val value: String)
fun cancelOrder(orderId: OrderId, customerId: CustomerId) { /* ... */ }
// Compiler catches this
cancelOrder(
orderId = CustomerId("cust-123"), // error!
customerId = OrderId("ord-456") // error!
)
Without value classes, both are String. With value classes, the compiler catches the mixup. At runtime, both are still just String — no boxing.
In a repository
interface OrderRepository {
suspend fun findById(id: OrderId): Order?
suspend fun findByCustomer(customerId: CustomerId): List<Order>
}
data class Order(
val id: OrderId,
val customerId: CustomerId,
val products: List<ProductId>,
val total: Double
)
Anyone reading this code knows exactly what type each ID is. No more “wait, is this the order ID or the customer ID?”
Practical: Units and measurements
Prevent unit confusion:
@JvmInline
value class Meters(val value: Double)
@JvmInline
value class Kilometers(val value: Double) {
fun toMeters(): Meters = Meters(value * 1000)
}
@JvmInline
value class Miles(val value: Double) {
fun toKilometers(): Kilometers = Kilometers(value * 1.60934)
}
fun calculateTravelTime(distance: Kilometers, speedKmh: Double): Double {
return distance.value / speedKmh
}
// Can't accidentally pass miles where kilometers are expected
val distance = Miles(100.0)
// calculateTravelTime(distance, 80.0) // won't compile
calculateTravelTime(distance.toKilometers(), 80.0) // correct
Practical: Validated types
Ensure values are valid at construction:
@JvmInline
value class NonBlankString(val value: String) {
init {
require(value.isNotBlank()) { "Value must not be blank" }
}
}
@JvmInline
value class PositiveInt(val value: Int) {
init {
require(value > 0) { "Value must be positive, got $value" }
}
}
@JvmInline
value class Port(val value: Int) {
init {
require(value in 1..65535) { "Port must be 1-65535, got $value" }
}
}
data class ServerConfig(
val host: NonBlankString,
val port: Port,
val maxConnections: PositiveInt
)
The validation runs once at construction. After that, you know the value is valid — no need to re-validate downstream.
When boxing happens
Value classes are unboxed most of the time. But boxing occurs in some situations:
@JvmInline
value class UserId(val value: String)
// Unboxed — UserId is erased to String
fun getUser(id: UserId): User { /* ... */ }
// Boxed — needs the wrapper to distinguish types
fun process(id: Any) { /* ... */ } // UserId passed as Any → boxed
val nullableId: UserId? = null // nullable → boxed
val list: List<UserId> = listOf() // generic type parameter → boxed
Boxing means an actual UserId object is created on the heap. This happens with:
- Nullable value classes (
UserId?) - Generic type parameters (
List<UserId>,Map<String, UserId>) - Passing as
Anyor interface types
For most use cases, this occasional boxing is negligible. Don’t avoid value classes because of it.
Inline functions
Separate from value classes, inline functions eliminate the overhead of lambda parameters. The compiler copies the function body and lambda body to the call site:
inline fun <T> measureTime(block: () -> T): Pair<T, Long> {
val start = System.nanoTime()
val result = block()
val elapsed = System.nanoTime() - start
return result to elapsed
}
// Usage
val (result, timeNs) = measureTime {
expensiveOperation()
}
Without inline, the lambda { expensiveOperation() } becomes an anonymous class — object creation + method call overhead. With inline, the compiler inlines everything:
// What the compiler generates (approximately)
val start = System.nanoTime()
val result = expensiveOperation()
val elapsed = System.nanoTime() - start
No lambda object, no virtual call.
When to use inline
- Higher-order functions (functions taking lambdas) where the lambda is called once
- Small, frequently-called utility functions
- Functions with reified type parameters
When not to use inline
- Functions with large bodies (code size increases at every call site)
- Functions where the lambda is stored, not called immediately
- Functions not taking lambdas (no benefit)
// Good — lambda called once, small body
inline fun <T> runCatching(block: () -> T): Result<T> {
return try { Result.success(block()) }
catch (e: Throwable) { Result.failure(e) }
}
// Bad — function body is large, inlining bloats bytecode
inline fun processLargeData(data: List<Item>, transform: (Item) -> Output): List<Output> {
// 50 lines of code...
}
Reified type parameters
Normally, generic type info is erased at runtime. inline with reified preserves it:
inline fun <reified T> isType(value: Any): Boolean {
return value is T // only works with reified
}
println(isType<String>("hello")) // true
println(isType<Int>("hello")) // false
Without reified, value is T won’t compile — T is erased. With reified, the compiler substitutes the actual type at the call site.
Practical use — parsing JSON:
import kotlinx.serialization.json.Json
import kotlinx.serialization.decodeFromString
inline fun <reified T> String.parseJson(): T {
return Json.decodeFromString<T>(this) // reified T used here
}
// Usage
val user = """{"name":"Alice","email":"[email protected]"}""".parseJson<User>()
Without reified, you’d need to pass the class explicitly: parseJson(User::class).
crossinline and noinline
When an inline function takes multiple lambdas, you can control which ones get inlined:
inline fun transaction(
crossinline block: () -> Unit, // inlined, but can't return from outer function
noinline onError: (Exception) -> Unit // not inlined, can be stored
) {
try {
block()
} catch (e: Exception) {
onError(e) // stored/passed, so can't be inlined
}
}
crossinline— inlined but prevents non-local returnsnoinline— not inlined, used when the lambda needs to be stored or passed around
Summary
| Feature | What it does | Use case |
|---|---|---|
| Value class | Type wrapper, erased at runtime | Type-safe IDs, units, validated types |
| Inline function | Copies function + lambda to call site | Higher-order utilities, measurements |
| Reified | Preserves generic type at runtime | Type checks, serialization helpers |
Value classes and inline functions are Kotlin’s way of saying: “Use abstractions. Make your code type-safe and expressive. We’ll handle the performance.” The compiler does the work so you don’t have to choose between clarity and speed.