Kotlin Inline Functions and Contracts Guide

06 March 2026

Kotlin Android

In the previous guide on higher-order functions, I mentioned that every lambda compiles to an anonymous class implementing one of the FunctionN interfaces. That’s a clean abstraction, but abstractions have costs. Each time you call a higher-order function like filter, map, or your own custom one, the JVM creates a Function1 or Function2 object, allocates it on the heap, and invokes its invoke method through a virtual dispatch. On most code paths, this is perfectly fine — the JVM’s garbage collector handles short-lived objects efficiently, and the overhead is negligible. But on hot paths — RecyclerView binding, custom drawing, tight loops processing thousands of items — those allocations add up fast. Every frame you have roughly 16 milliseconds of budget, and burning part of it on lambda object creation and GC pauses is waste you can eliminate entirely.

This is exactly what inline solves. When you mark a function as inline, the Kotlin compiler doesn’t generate a function call at all. Instead, it copies the entire function body — and the lambda body — directly into the call site at compile time. No Function object, no virtual call, no heap allocation. The generated bytecode looks as if you wrote the loop and the logic yourself, right there in the calling function. The Kotlin standard library team understood this from day one, which is why let, run, apply, also, with, filter, map, forEach, flatMap, and essentially every higher-order function that takes a lambda and runs it immediately are all marked inline. It’s not an optimization afterthought — it’s a deliberate design decision baked into the language’s approach to functional programming on the JVM.

But inline does more than just eliminate allocation. It unlocks three capabilities that aren’t possible without it: non-local returns, crossinline control, and reified type parameters. And when you combine inline with Kotlin’s contracts system, you get compiler guarantees about how lambdas execute that enable things like val initialization inside lambda blocks. These features fit together into a cohesive system, and understanding how they interact is what separates surface-level Kotlin from the kind of code you’d find in the standard library source.

How Inline Works

The mechanics are straightforward but the implications are significant. When the compiler encounters an inline function call, it takes the function body and pastes it directly at the call site. It also takes the lambda argument and pastes that body inline too. The result is zero abstraction overhead — the generated bytecode is identical to what you’d write if you hand-coded the logic without any higher-order function.

Consider a simple timing utility:

inline fun <T> measureTime(block: () -> T): T {
    val start = System.nanoTime()
    val result = block()
    val elapsed = System.nanoTime() - start
    Log.d("Perf", "Took ${elapsed / 1_000_000}ms")
    return result
}

// Call site
val users = measureTime {
    userRepository.fetchActiveUsers()
}

Without inline, this creates a Function0<List<User>> object, calls measureTime as a normal function, and inside that function, invokes block.invoke(). With inline, the compiler generates something equivalent to:

// What the compiler actually produces at the call site
val start = System.nanoTime()
val users = userRepository.fetchActiveUsers()
val elapsed = System.nanoTime() - start
Log.d("Perf", "Took ${elapsed / 1_000_000}ms")

No function call. No object creation. The measureTime function body and the lambda body are flattened into the surrounding code as if they were always there. This is why the standard library’s measureTimeMillis and measureNanoTime are inline — a timing utility that adds its own overhead to the measurement would defeat its own purpose.

The performance difference is real and measurable. In a tight loop running 100,000 iterations, a non-inline higher-order function creates 100,000 Function objects that the GC has to collect. The inline version creates zero. On Android, where GC pauses can cause dropped frames, this matters. I’ve profiled apps where replacing a custom non-inline runIf helper with an inline version eliminated a noticeable stutter in a LazyColumn that was rebinding hundreds of items during fast scrolling.

Non-Local Returns

Here’s where inlining gets interesting beyond performance. Because an inline lambda’s body is pasted directly into the calling function’s bytecode, a return statement inside that lambda doesn’t return from the lambda — it returns from the enclosing function. This is called a non-local return, and it’s the reason return works inside forEach the way you’d expect it to work inside a for loop.

fun findFirstAdmin(users: List<User>): User? {
    users.forEach { user ->
        if (user.role == Role.ADMIN) {
            return user // Returns from findFirstAdmin, not just the lambda
        }
    }
    return null
}

This works because forEach is inline. The lambda body is pasted into findFirstAdmin’s bytecode, so return user is a normal return from findFirstAdmin. If forEach were not inline, this would be a compiler error — you can’t return from an enclosing function through a non-inline lambda, because the lambda is a separate Function object with its own execution context.

This is a subtle but powerful feature. It means inline higher-order functions can participate in control flow the same way built-in language constructs do. You can use return inside run { } to exit the enclosing function. You can use return inside with(obj) { } to bail early. The standard library is designed so that these scope functions feel like language keywords, and non-local returns are a big part of why they feel that way.

One thing to be aware of: if you only want to return from the lambda itself (not the enclosing function), use a labeled return like return@forEach. This becomes important when you’re using forEach but want to skip an item rather than exit the whole function — return@forEach is the equivalent of continue in a for loop.

noinline and crossinline

Not every lambda parameter in an inline function should be inlined. Sometimes you need to store a lambda in a property, pass it to another function that isn’t inline, or use it in a way that’s incompatible with inlining. That’s what noinline is for — it excludes a specific lambda parameter from the inlining process.

inline fun trackEvent(
    eventName: String,
    properties: () -> Map<String, String>,
    noinline onComplete: () -> Unit
) {
    val props = properties() // This lambda IS inlined
    analyticsService.track(eventName, props, onComplete) // onComplete is passed as an object
}

In this example, properties is inlined normally — its body is pasted into the call site. But onComplete needs to be passed to analyticsService.track(), which is a regular function expecting a Function0 object. You can’t paste inlined bytecode into a regular function parameter — it needs an actual object reference. Marking it noinline tells the compiler to create the Function0 object for that parameter only, while still inlining everything else. You’d also use noinline when you need to store a lambda in a variable or property for later execution.

crossinline solves a different problem. Sometimes an inline function needs to invoke a lambda from a different execution context — inside an anonymous object, another lambda, or a nested function. In these cases, non-local returns can’t work because the lambda isn’t directly part of the enclosing function’s bytecode flow anymore. crossinline marks a lambda that will be inlined but cannot use non-local returns.

inline fun launchWithRetry(
    maxAttempts: Int,
    crossinline block: suspend () -> Unit
) {
    CoroutineScope(Dispatchers.IO).launch {
        repeat(maxAttempts) { attempt ->
            try {
                block() // Called inside launch's lambda — different execution context
                return@launch
            } catch (e: Exception) {
                Log.w("Retry", "Attempt $attempt failed: ${e.message}")
            }
        }
    }
}

The block lambda is marked crossinline because it’s invoked inside the launch coroutine lambda — a different execution context. The lambda body will still be inlined (you get the allocation benefit), but the compiler prevents anyone from using return inside block to exit the enclosing function, because that control flow would be meaningless across coroutine boundaries. Without crossinline, the compiler would refuse to compile this code entirely, because it can’t guarantee non-local return safety.

My mental model for these modifiers is simple: noinline means “don’t inline this lambda at all, I need it as an object.” crossinline means “do inline this lambda, but forbid non-local returns because the execution context doesn’t support them.”

Reified Type Parameters

This is, I think, the most practically useful feature that inline enables, and it solves a problem that every JVM developer has hit. On the JVM, generic type parameters are erased at runtime. If you write fun <T> parse(json: String): T, by the time the function runs, the JVM has no idea what T actually is. You can’t write T::class, you can’t do is T checks, you can’t create instances of T. The type is gone. This is why Java APIs end up taking Class<T> parameters everywhere — it’s the only way to pass type information to runtime code.

reified type parameters on inline functions solve this entirely. Because the function body is copied to the call site, and the call site knows the concrete type, the compiler can substitute the actual type directly into the inlined code. The type information is preserved because the inlining happens at compile time — there’s nothing to erase.

inline fun <reified T> fromJson(json: String): T {
    val adapter = moshi.adapter(T::class.java)
    return adapter.fromJson(json)
        ?: throw JsonDataException("Failed to parse ${T::class.simpleName}")
}

// Call site — no Class parameter needed
val user: User = fromJson("""{"name":"Mukul","role":"admin"}""")
val config: AppConfig = fromJson(configJsonString)

Without reified, you’d write fromJson<User>(json, User::class.java) — redundant and noisy. With reified, the compiler inlines the call and replaces T::class.java with User::class.java at the call site. The generated bytecode is identical to writing moshi.adapter(User::class.java) directly.

The standard library uses this pattern in filterIsInstance, which filters a collection to only elements of a specific type:

inline fun <reified T> Iterable<*>.filterIsInstance(): List<T> {
    val result = mutableListOf<T>()
    for (element in this) {
        if (element is T) { // Only possible with reified
            result.add(element)
        }
    }
    return result
}

// Usage
val mixedViews: List<View> = parentLayout.children.toList()
val buttons: List<Button> = mixedViews.filterIsInstance<Button>()

The element is T check compiles because T is reified — the compiler substitutes the actual type (Button) at the call site, turning it into element is Button. Without reified, you’d need to pass a Class<T> parameter and use clazz.isInstance(element), which is reflection and significantly slower.

One important limitation: reified only works with inline functions, and it only works with concrete types at the call site. You can’t call a reified function with another generic type parameter that isn’t itself reified — the compiler needs to know the actual type at compile time.

Contracts

Contracts are an experimental feature that lets you tell the compiler things about your function’s behavior that it can’t figure out on its own. The compiler is smart, but it has limits. It can smart-cast inside an if block, but it can’t smart-cast through a function call. It knows a for loop body runs zero or more times, but it doesn’t know that your custom synchronized block runs its lambda exactly once.

The callsInPlace contract is the one I use most. It tells the compiler how many times a lambda parameter will be invoked — EXACTLY_ONCE, AT_MOST_ONCE, or AT_LEAST_ONCE. This enables val initialization inside lambda blocks, which would otherwise be a compiler error.

@OptIn(ExperimentalContracts::class)
inline fun <T> executeExactlyOnce(block: () -> T): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}

fun processOrder(orderId: String) {
    val timestamp: Long // Not initialized
    executeExactlyOnce {
        timestamp = System.currentTimeMillis() // Compiler allows this
    }
    println("Order $orderId processed at $timestamp") // Compiler knows timestamp is initialized
}

Without the contract, the compiler would flag timestamp as potentially uninitialized at the println line. It can’t prove that block() runs exactly once just by looking at the function signature. The contract bridges that gap — you’re promising the compiler that the lambda runs exactly once, and it trusts you. The standard library’s run, let, with, apply, and also all have callsInPlace(block, EXACTLY_ONCE) contracts, which is why you can initialize val variables inside them.

The returns contract enables smart casts through function calls. The standard library’s require() and check() use this:

@OptIn(ExperimentalContracts::class)
fun requireNotBlank(value: String?) {
    contract {
        returns() implies (value != null)
    }
    if (value.isNullOrBlank()) {
        throw IllegalArgumentException("Value must not be blank")
    }
}

fun handleUserInput(input: String?) {
    requireNotBlank(input)
    // Compiler smart-casts 'input' to String (non-null) here
    println("Processing: ${input.uppercase()}")
}

The contract says: “if this function returns normally (doesn’t throw), then value != null is guaranteed.” The compiler uses this to smart-cast input to String after the call. Without contracts, the compiler would still treat input as String? even though the function guarantees it’s not null by throwing on null/blank values. This is exactly how requireNotNull(), checkNotNull(), and check() work in the standard library — they all have returns() implies contracts.

One honest caveat: contracts are still experimental. The API has been stable enough that the standard library relies on them heavily, and they haven’t changed significantly since Kotlin 1.3. But the compiler doesn’t verify that your contract is correct — if you declare callsInPlace(block, EXACTLY_ONCE) but actually call block twice, the compiler will trust your lie and you’ll get undefined behavior. Write contracts carefully, and only for functions where the guarantee is genuinely true.

When to Use Inline

After all this, you might be tempted to mark everything inline. Don’t. The Kotlin compiler will actually warn you if you write an inline function that has no lambda parameters and no reified type parameters — because in that case, inlining just bloats the bytecode without any benefit. The function body gets copied to every call site, increasing your APK’s dex method count and potentially hurting instruction cache performance.

The standard library follows a clear rule, and I think it’s the right one. Mark a function inline when it takes lambda parameters that are invoked immediately — the allocation savings justify the code duplication. Mark it inline when you need reified type parameters — there’s no alternative. Don’t mark it inline when the function body is large (more than 10-15 lines of logic), because the bytecode bloat at every call site outweighs the allocation savings. And definitely don’t mark it inline when the function doesn’t take any lambda parameters — there’s literally no benefit, and the compiler will tell you so.

There’s a real-world tradeoff here that most guides don’t mention. Inlining a function means its body is copied into every caller. If you change the implementation of an inline function in a library, every consumer needs to recompile — the old inlined bytecode is baked into their compiled classes. For public API functions, this means inline functions become part of your binary compatibility contract. The Kotlin team handles this with @PublishedApi for internal helpers that inline functions need to call, and you should think about the same considerations if you’re writing library code. For application code, where you recompile everything together, this isn’t a concern.

My personal heuristic: if the function takes a lambda, runs it immediately, and the function body is short, make it inline. If it needs reified, make it inline. Everything else, leave it alone. The standard library got this right from the start, and following the same pattern has never steered me wrong.

Thanks for reading!

Quiz

What is the primary benefit of inline functions?

Explanation: inline copies the function and lambda bodies directly into the calling function’s bytecode. No Function object is created, no virtual call happens. This is why let, run, apply, filter, and map are all inline in the standard library.

What does reified allow you to do?

Explanation: Normally, generic type parameters are erased at runtime on the JVM — you can’t check is T or access T::class. reified on an inline function inlines the actual type at the call site, preserving type information at runtime.

Coding Challenge

Write an inline function retry with: maxAttempts: Int, a crossinline onRetry: (attempt: Int, exception: Exception) -> Unit callback, and a block: () -> T lambda. It should retry the block up to maxAttempts times, calling onRetry on failures. Also write an inline function <reified T> safeJsonParse(json: String): Result<T> that uses reified types to parse JSON safely. Include a contract that tells the compiler the block runs at least once.

Solution

@OptIn(ExperimentalContracts::class)
inline fun <T> retry(
    maxAttempts: Int,
    crossinline onRetry: (attempt: Int, exception: Exception) -> Unit,
    block: () -> T
): T {
    contract {
        callsInPlace(block, InvocationKind.AT_LEAST_ONCE)
    }
    require(maxAttempts > 0) { "maxAttempts must be positive" }

    var lastException: Exception? = null
    repeat(maxAttempts) { attempt ->
        try {
            return block()
        } catch (e: Exception) {
            lastException = e
            if (attempt < maxAttempts - 1) {
                onRetry(attempt + 1, e)
            }
        }
    }
    throw lastException!!
}

inline fun <reified T> safeJsonParse(json: String): Result<T> {
    return try {
        val adapter = moshi.adapter(T::class.java)
        val parsed = adapter.fromJson(json)
        if (parsed != null) {
            Result.success(parsed)
        } else {
            Result.failure(
                JsonDataException("Null result parsing ${T::class.simpleName}")
            )
        }
    } catch (e: Exception) {
        Result.failure(e)
    }
}

// Usage
val config: AppConfig = retry(
    maxAttempts = 3,
    onRetry = { attempt, error ->
        Log.w("Config", "Attempt $attempt failed: ${error.message}")
        Thread.sleep(1000L * attempt)
    }
) {
    configService.fetchRemoteConfig()
}

val userResult: Result<User> = safeJsonParse<User>(responseBody)
userResult.onSuccess { user -> updateUI(user) }
    .onFailure { error -> showError(error.message) }