PCSalt
YouTube GitHub
Back to Kotlin
Kotlin · 3 min read

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

  1. Exactly one property in the primary constructor
  2. Must be annotated with @JvmInline
  3. Can have methods, properties, init blocks
  4. 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 Any or 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 returns
  • noinline — not inlined, used when the lambda needs to be stored or passed around

Summary

FeatureWhat it doesUse case
Value classType wrapper, erased at runtimeType-safe IDs, units, validated types
Inline functionCopies function + lambda to call siteHigher-order utilities, measurements
ReifiedPreserves generic type at runtimeType 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.