Kotlin Functions — Lambdas, Extensions, and DSLs
How Kotlin treats functions as first-class values, why inline functions exist, how extension functions add methods to types you don't own, and how lambdas-with-receiver turn Kotlin into a language for building DSLs.
Functions as first-class values
In Java before version 8, a function could not exist on its own. To pass behaviour around, you wrapped it in an object — an anonymous inner class implementing some single-method interface. Kotlin treats functions as first-class values from the start: a function can be stored in a variable, passed as an argument, and returned from another function, just like an Int or a String.
Functions also do not need to live inside a class. Kotlin allows top-level functions — declared directly in a file, no surrounding class required. This is why fun main() sits alone, and why the standard library is full of free functions like maxOf and println rather than static methods bolted onto utility classes.
Every function has a function type, written with an arrow. (Int, Int) -> Int is the type of a function taking two Ints and returning an Int.
// A variable holding a function
val add: (Int, Int) -> Int = { a, b -> a + b }
println(add(2, 3)) // 5
// A reference to an existing function with ::
val printer: (Any?) -> Unit = ::println
printer("hello")
The :: operator creates a function reference — a way to grab an existing named function as a value without calling it. These pieces — top-level functions, function types, lambdas, and references — are the vocabulary everything else in this episode is built from.
Higher-order functions and lambdas
A higher-order function is one that takes a function as a parameter or returns one. They are the backbone of Kotlin's collection operations — map, filter, forEach all take a lambda describing what to do with each element.
Kotlin has a small syntactic convention that makes these read beautifully. If the last parameter of a function is itself a function, you can move the lambda outside the parentheses — the trailing lambda. And if a lambda has a single parameter, you can refer to it with the implicit name it instead of naming it.
val numbers = listOf(1, 2, 3, 4, 5)
// Fully explicit
numbers.filter({ n -> n % 2 == 0 })
// Trailing lambda (last arg moved out of parens)
numbers.filter { n -> n % 2 == 0 }
// Implicit single parameter 'it'
numbers.filter { it % 2 == 0 } // [2, 4]
That trailing-lambda rule is more far-reaching than it first appears. It is the reason functions like repeat(5) { ... } and, ultimately, entire DSLs read like built-in language constructs rather than function calls. We will lean on it heavily at the end of this episode.
Why inline functions exist
Lambdas are convenient, but on the JVM they are not free. Each lambda is normally compiled into an object, and capturing variables from the surrounding scope allocates memory. For a function called millions of times — like the lambda you pass to forEach in a hot loop — that overhead adds up.
Kotlin's answer is the inline keyword. When a higher-order function is marked inline, the compiler does not generate a function call at all. Instead it copies the function's body — and the body of the lambda you passed — directly into the call site.
The result is zero call overhead and zero lambda allocation. This is why so much of the standard library's lambda-taking machinery — let, run, apply, filter, map — is inline. Inlining also unlocks two abilities ordinary lambdas lack: non-local returns (a return inside the lambda can return from the enclosing function, because there is no separate function to return from) and the reified type parameters covered in the Type System episode.
The trade-off: inlining copies code, so over-inlining a large function called in many places bloats the bytecode. Use inline for small higher-order functions, not as a blanket performance fix. The compiler will even warn you when inlining buys nothing.
Extension functions
This is the feature that most changes how Kotlin feels. An extension function lets you add a new method to a class you do not own — without inheriting from it, without modifying its source, without any wrapper.
// Add a method to the standard String class
fun String.shout(): String = this.uppercase() + "!"
"hello".shout() // "HELLO!"
Inside the extension, this refers to the instance it was called on (the receiver). To the caller, shout() looks and feels exactly like a built-in method of String, complete with autocomplete in the IDE.
There is an important subtlety: extensions are resolved statically. They are not real members of the class — under the hood,
"hello".shout() compiles to a plain static call like shout("hello"). This means an extension cannot access a class's private internals, and it cannot be overridden polymorphically the way a real method can. Which extension is called is decided by the declared type of the variable, not its runtime type.
Despite that limitation — or because of it — extensions are everywhere in idiomatic Kotlin. Huge swathes of the standard library are extension functions: nearly all the collection operators (
map, filter, groupBy) are extensions on Iterable. They let you keep classes small and focused while adding rich, discoverable functionality from the outside. C# pioneered this idea (its "extension methods" arrived with LINQ in 2007); Kotlin embraced it as a core part of its style.
Scope functions: let, run, with, apply, also
Kotlin's standard library includes five small, famously-confusing scope functions. They all do roughly the same thing — execute a block of code in the context of an object — but they differ on two axes: how the object is referenced inside the block (as this or as it), and what the block returns (the object itself, or the block's result).
Two examples make the distinction click. apply returns the receiver, so it is perfect for configuring an object you are creating:
val intent = Intent().apply {
action = "VIEW" // 'this' is the Intent
putExtra("id", 42)
} // returns the configured Intent
let exposes the object as it and returns the block's result, making it the idiomatic tool for "do something only if non-null":
val length: Int? = name?.let {
println("got $it")
it.length // this becomes the result
}
The choice between them is not arbitrary style — it expresses intent. apply/also say "I am still working with this object." let/run/with say "I am transforming it into something else."
Lambdas with receiver, and DSLs
We now have every ingredient for Kotlin's most powerful trick. Notice that in the apply example, inside the lambda you could write action = "VIEW" as if you were inside the Intent class. That is a lambda with receiver — a function type written Intent.() -> Unit, meaning "a lambda whose this is an Intent."
Combine a function with a receiver-lambda parameter with the trailing-lambda syntax, and ordinary function calls start to look like a custom mini-language. This is how Kotlin builds DSLs (domain-specific languages). The classic example is an HTML builder:
val page = html { // html { } is a function taking an HTML.() -> Unit
body {
h1 { +"Welcome" }
p { +"Built with a Kotlin DSL" }
}
}
Every line there is a normal function call with a trailing receiver-lambda. body { } is a method on the html receiver; inside it, this is a Body, so h1 { } resolves against it. The whole thing type-checks at compile time — typos and misplaced tags are errors, not runtime surprises. This same pattern powers Jetpack Compose (Column { Text("hi") }), the Gradle Kotlin build scripts, kotlinx.html, and Ktor's routing.
One refinement keeps these DSLs honest: the
@DslMarker annotation. Without it, nested builders could accidentally call methods from an outer receiver (writing an h1 directly inside html rather than body). @DslMarker tells the compiler to restrict each block to its own receiver, catching those mistakes. The supporting cast — infix functions (write 1 to "a" instead of 1.to("a")) and operator overloading (define what + means for your type) — adds the final polish that lets a Kotlin DSL read like prose.
AI prompts — functions, lambdas, and DSLs
Use these prompts with an AI assistant to go deeper on the ideas in this episode.
Prompt 1 — Choosing a scope function:
For each snippet below, tell me which scope function (let, run,
with, apply, also) is most idiomatic and why, considering whether
I need 'this' or 'it' and whether I want the object or a result back:
[paste snippets]
Prompt 2 — Designing extension functions:
I keep writing this utility logic on [some type]:
[paste code]
Turn it into clean extension functions, and note any cases where
an extension is the wrong choice (e.g. needing private access).
Prompt 3 — When to inline:
Should this higher-order function be marked inline?
[paste function]
Explain the allocation/overhead trade-off, whether non-local
returns or reified types matter here, and any bytecode-bloat risk.
Prompt 4 — Build a DSL:
Design a small type-safe Kotlin DSL for [describe your domain,
e.g. configuring a form / building a query / defining test cases].
Use lambdas with receiver and @DslMarker, and show example usage.
Kotlin Functions — Quiz
7 questions
Seven questions on first-class functions, lambdas, inline functions, extensions, scope functions, and DSLs.