Gradle's Kotlin DSL, Ktor routing, Compose and kotlinx.html are all built this way; understanding it demystifies them.
A lambda with receiver has a type like StringBuilder.() -> Unit. Inside it, this is the receiver, so you can call its members without qualification. The standard buildString { append("x") } and apply { } work this way.
A type-safe builder combines this with functions that create child objects:
fun html(init: Html.() -> Unit): Html = Html().apply(init)
class Html { fun body(init: Body.() -> Unit) { children += Body().apply(init) } }Calls then read like a declarative language: html { body { p("hi") } }, yet everything is checked by the compiler and IDE completion works.
Ingredients for nice DSLs:
- Lambdas with receivers for nesting.
- Trailing lambda syntax to drop parentheses.
- Extension functions and
operatoroverloads (e.g.unaryPlusfor+"text"). infixfunctions for phrases like"a" shouldBe "a".@DslMarkeron an annotation applied to builder classes: it stops inner lambdas from implicitly calling members of outer receivers, preventing mistakes like callingbody()insidep { }.
Kotlin 2.2 also previews context parameters (context(logger: Logger) fun ...), letting DSL functions require ambient dependencies without being extensions.
Gradle's build.gradle.kts is exactly this: dependencies { implementation("...") } calls a function taking a DependencyHandlerScope.() -> Unit. Keep DSLs small and use them where the structure is genuinely hierarchical.
Example
@DslMarker annotation class HtmlDsl
@HtmlDsl abstract class Tag(val name: String) {
val children = mutableListOf<Any>()
override fun toString() = "<$name>${children.joinToString("")}</$name>"
}
class Html : Tag("html") { fun body(init: Body.() -> Unit) { children += Body().apply(init) } }
class Body : Tag("body") {
fun p(init: P.() -> Unit) { children += P().apply(init) }
fun ul(vararg items: String) { children += Ul().apply { items.forEach { +it } } }
}
class P : Tag("p") { operator fun String.unaryPlus() { children += this } }
class Ul : Tag("ul") { operator fun String.unaryPlus() { children += "<li>$this</li>" } }
fun html(init: Html.() -> Unit): Html = Html().apply(init)
fun main() {
val page = html {
body {
p { +"Hello, DSL" }
ul("one", "two")
// p { body { } } // error thanks to @DslMarker
}
}
println(page) // <html><body><p>Hello, DSL</p><ul><li>one</li><li>two</li></ul></body></html>
}Remember
- T.() -> Unit is a lambda with receiver: this is a T inside
- Builders = functions taking receiver lambdas that create children
- @DslMarker prevents accidental calls on outer receivers
- Gradle Kotlin DSL, Ktor and Compose use these techniques