Skip to content
AgileLogics
6 min read

Type-Safe DSLs

Prerequisites: Scope functions, Extension functions

A DSL (domain-specific language) is an API designed to read like a description rather than like code. You've already used Kotlin DSLs: Gradle build scripts, Ktor routing, and Jetpack Compose are all built from ordinary Kotlin features.

The key ingredient: lambdas with receivers

A function type like StringBuilder.() -> Unit is a lambda with a receiver: inside it, this is a StringBuilder, so its methods can be called without a prefix. The standard buildString uses one:

fun main() {
    val text = buildString {
        append("Kotlin")
        append(" ")
        append("DSLs")
    }
    println(text)
}
Kotlin DSLs

Building your own

@DslMarker
annotation class MenuDsl

@MenuDsl
class Menu(val title: String) {
    private val items = mutableListOf<Pair<String, Double>>()

    fun item(name: String, price: Double) {
        items += name to price
    }

    override fun toString() = buildString {
        appendLine("== $title ==")
        items.forEach { (name, price) -> appendLine("${name.padEnd(12)}$${"%.2f".format(price)}") }
    }.trimEnd()
}

fun menu(title: String, build: Menu.() -> Unit): Menu = Menu(title).apply(build)

fun main() {
    val breakfast = menu("Breakfast") {
        item("Espresso", 3.0)
        item("Croissant", 4.5)
    }
    println(breakfast)
}
== Breakfast ==
Espresso    $3.00
Croissant   $4.50
  • menu(title, build: Menu.() -> Unit) takes a lambda with a Menu receiver, so item(...) inside the braces means this.item(...).
  • Menu(title).apply(build) creates the menu, runs the lambda on it, and returns it.
  • @DslMarker stops nested blocks from accidentally calling methods of an outer receiver — important once a DSL has several levels, like HTML inside HTML.

When to write one

DSLs shine for configuration, builders, and structured documents — code that's mostly data. For ordinary logic, regular functions and classes are clearer and easier to debug.