Type-safe builders and DSLs

Kotlin · Expert · 14 min

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 operator overloads (e.g. unaryPlus for +"text").
  • infix functions for phrases like "a" shouldBe "a".
  • @DslMarker on an annotation applied to builder classes: it stops inner lambdas from implicitly calling members of outer receivers, preventing mistakes like calling body() inside p { }.

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

Check yourself

  1. Inside a lambda of type StringBuilder.() -> Unit, what does append("x") call?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: kotlinlang.org/docs/type-safe-builders.html