DSLs, Lambdas with Receivers, and Inline Functions
DSLs, Lambdas with Receivers, and Inline Functions
Look at this Gradle build configuration:
plugins {
kotlin("jvm") version "2.1.0"
application
}
dependencies {
implementation("io.ktor:ktor-server-core:3.0.0")
testImplementation(kotlin("test"))
}
application {
mainClass.set("com.example.MainKt")
}
Or this HTML builder:
val page = html {
head {
title("Kotlin DSLs")
stylesheet("/css/main.css")
}
body {
h1("Welcome")
div(classes = "content") {
p("This reads like a markup language.")
p("But it's fully compiled Kotlin.")
}
}
}
If you’re coming from Java, your first instinct might be that this requires compiler plugins, annotation processors, or custom syntax extensions. It doesn’t. This is three regular language features composed together:
- Lambdas with receivers — a lambda where
thisis bound to a specific object - Extension functions — functions that appear to extend a type without modifying it
- Trailing lambda syntax — when the last parameter is a lambda, it moves outside the parentheses
That’s the entire toolkit. No compiler magic, no code generation. Understanding how these three features interact gives you the ability to design APIs that are expressive, type-safe, and compile down to the same bytecode as hand-written builder calls.
The T.() -> R Type Signature
In Java, a lambda is a function that takes arguments and returns a result. In Kotlin, there’s an additional variant: a lambda with a receiver. The type signature T.() -> R means “a function that can be called on an instance of T, with no explicit parameters, returning R.”
Inside this lambda, this refers to the receiver instance of type T.
// Regular lambda: String is a parameter
val greet: (String) -> String = { name -> "Hello, $name" }
// Lambda with receiver: String is `this`
val greetReceiver: String.() -> String = { "Hello, $this" }
// Both produce the same result
greet("Kotlin") // "Hello, Kotlin"
"Kotlin".greetReceiver() // "Hello, Kotlin"
The critical difference is scoping. Inside a lambda with receiver, every unqualified method call and property access resolves against the receiver. This is what makes DSL syntax possible — you call head { }, body { }, p("text") without any prefix because this is the enclosing builder object.
Compare this with how Java forces you to express the same intent:
// Java Builder pattern
HtmlBuilder html = new HtmlBuilder();
HeadBuilder head = html.head();
head.title("Kotlin DSLs");
head.stylesheet("/css/main.css");
BodyBuilder body = html.body();
body.h1("Welcome");
DivBuilder div = body.div("content");
div.p("This reads like a markup language.");
div.p("But it's fully compiled Kotlin.");
String result = html.build();
Every call requires an explicit receiver. The structure of the document is obscured by the noise of variable declarations and method chaining. The Kotlin DSL version preserves the document’s hierarchical structure in the code itself.
How apply, with, and run Use Receivers
You’ve likely used apply, with, and run without thinking about them as lambdas with receivers. They are — and the differences between them come down to how the receiver is provided and what gets returned.
// apply: receiver is `this`, returns the receiver
val connection = DatabaseConnection().apply {
host = "localhost" // this.host = "localhost"
port = 5432 // this.port = 5432
maxRetries = 3 // this.maxRetries = 3
}
// with: receiver passed as argument, returns lambda result
val summary = with(connection) {
"Connected to $host:$port (retries: $maxRetries)"
}
// run: receiver is `this`, returns lambda result
val isReady = connection.run {
host.isNotEmpty() && port > 0
}
Here’s how they’re actually declared in the standard library:
public inline fun <T> T.apply(block: T.() -> Unit): T {
block() // calls the lambda with `this` as receiver
return this // returns the receiver itself
}
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
return receiver.block() // calls block on receiver, returns result
}
public inline fun <T, R> T.run(block: T.() -> R): R {
return block() // calls the lambda with `this` as receiver, returns result
}
Notice: every one of them is inline and every one takes a T.() -> R or T.() -> Unit. The entire standard library scope function family is built on lambdas with receivers and inline functions. There’s no special treatment by the compiler.
Anatomy of a DSL Builder
A typical DSL builder consists of three layers:
Layer 1: Builder classes that accumulate state.
class HtmlBuilder {
private val children = mutableListOf<Element>()
fun head(init: HeadBuilder.() -> Unit) {
val builder = HeadBuilder()
builder.init() // execute lambda with HeadBuilder as receiver
children.add(builder.build())
}
fun body(init: BodyBuilder.() -> Unit) {
val builder = BodyBuilder()
builder.init()
children.add(builder.build())
}
}
Layer 2: Entry-point functions that create the top-level builder and pass it as receiver.
fun html(init: HtmlBuilder.() -> Unit): HtmlDocument {
val builder = HtmlBuilder()
builder.init()
return builder.build()
}
Layer 3: Extension functions and properties that provide the “vocabulary” of the DSL.
class BodyBuilder {
fun h1(text: String) { /* ... */ }
fun p(text: String) { /* ... */ }
fun div(classes: String = "", init: DivBuilder.() -> Unit) { /* ... */ }
}
When a user writes html { body { p("text") } }, the compiler resolves each call through the receiver chain: p resolves on BodyBuilder, body resolves on HtmlBuilder, and html is the entry point.
The Problem: Scope Leaking
Without restrictions, nested lambdas with receivers allow implicit access to all enclosing receivers:
html {
body {
head { // Compiles! But this is wrong — head should only be inside html, not body
title("Oops")
}
}
}
Inside the body { } lambda, this is BodyBuilder, but the outer this (the HtmlBuilder) is still accessible. If HtmlBuilder has a head method, it resolves through the implicit outer receiver. The code compiles but produces an incorrect document structure.
@DslMarker: Scope Control
The @DslMarker meta-annotation solves this by telling the compiler to prohibit implicit access to outer receivers of the same DSL family:
@DslMarker
annotation class HtmlDsl
@HtmlDsl
class HtmlBuilder { /* ... */ }
@HtmlDsl
class BodyBuilder { /* ... */ }
@HtmlDsl
class HeadBuilder { /* ... */ }
Now the earlier mistake produces a compile error:
html {
body {
head { } // ERROR: 'fun head(init: HeadBuilder.() -> Unit)' can't be called
// in this context by implicit receiver. Use the explicit receiver
// 'this@html' if necessary.
}
}
The user can still call [email protected] { } if they genuinely intend it, but accidental scope leaking is caught at compile time.
Java has no equivalent mechanism. The Builder pattern in Java doesn’t have scope — every builder method is called on an explicit variable, so there’s never ambiguity. But you also lose the structural clarity that DSL syntax provides.
Inline Functions: Why DSLs Have Zero Overhead
Every scope function, every DSL builder call that takes a lambda — if these created anonymous inner class instances, DSLs would generate enormous amounts of garbage. You’d pay for object allocation on every apply, every html { }, every nested body { }.
The inline keyword eliminates this cost entirely. When a function is marked inline, the compiler copies the function’s body and the lambda’s body directly into the call site. No function call occurs. No lambda object is created.
inline fun <T> T.apply(block: T.() -> Unit): T {
block()
return this
}
When you write:
val config = Config().apply {
timeout = 30
retries = 3
}
The compiler generates bytecode equivalent to:
val config = Config()
config.timeout = 30
config.retries = 3
No apply call. No lambda allocation. The DSL syntax is purely a compile-time abstraction over direct property assignments. This is what makes Kotlin DSLs viable for performance-sensitive code — the abstraction cost is zero.
But inlining introduces its own complexities: non-local returns become possible, certain lambda usages become illegal, and the compiler needs additional information about how lambdas are invoked. The subsections that follow cover these mechanics in depth.
The next section walks through building a complete type-safe DSL from scratch. The section after that covers crossinline, noinline, Kotlin contracts, and the full spectrum of inline function behavior.