PCSalt
YouTube GitHub
Back to Kotlin
Kotlin · 3 min read

Kotlin DSL — Build Your Own Type-Safe Builder

Learn how to create type-safe DSLs in Kotlin using lambdas with receivers, extension functions, and builder patterns. Build a real HTML DSL and a config DSL from scratch.


You’ve already used Kotlin DSLs — build.gradle.kts, Ktor routing, Compose UI. They all share the same pattern: code that reads like configuration but is actually type-safe, auto-completed, and compiler-checked.

// Gradle DSL
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}

// Ktor DSL
routing {
    get("/users") {
        call.respond(userService.getAll())
    }
}

This isn’t magic. It’s built on two Kotlin features: lambdas with receivers and extension functions. Once you understand these, you can build your own DSLs.

Lambdas with receivers

A regular lambda:

val greet: (String) -> String = { name -> "Hello, $name" }
println(greet("Kotlin")) // Hello, Kotlin

A lambda with receiver:

val greet: String.() -> String = { "Hello, $this" }
println("Kotlin".greet()) // Hello, Kotlin

The difference: inside the lambda, this refers to the receiver object. You can call its methods and properties directly — no prefix needed.

This is the foundation of every Kotlin DSL. When you write:

dependencies {
    implementation("...")
}

The { } block is a lambda with DependencyHandler as the receiver. Inside it, you can call implementation() directly because it’s a method on DependencyHandler.

Building a simple DSL: HTML

Let’s build a small HTML DSL step by step. The goal:

val page = html {
    head {
        title("My Page")
    }
    body {
        h1("Welcome")
        p("This is a paragraph.")
        p("Another paragraph.")
    }
}
println(page)

Step 1: Define the model

class Html {
    private var headContent = ""
    private var bodyContent = ""

    fun head(block: Head.() -> Unit) {
        val head = Head()
        head.block()
        headContent = head.render()
    }

    fun body(block: Body.() -> Unit) {
        val body = Body()
        body.block()
        bodyContent = body.render()
    }

    fun render(): String = "<html>\n$headContent\n$bodyContent\n</html>"
}

class Head {
    private var titleText = ""

    fun title(text: String) {
        titleText = text
    }

    fun render(): String = "<head>\n  <title>$titleText</title>\n</head>"
}

class Body {
    private val elements = mutableListOf<String>()

    fun h1(text: String) {
        elements.add("  <h1>$text</h1>")
    }

    fun p(text: String) {
        elements.add("  <p>$text</p>")
    }

    fun render(): String = "<body>\n${elements.joinToString("\n")}\n</body>"
}

Step 2: Add the entry point

fun html(block: Html.() -> Unit): String {
    val html = Html()
    html.block()
    return html.render()
}

That’s it. html { } creates an Html instance, runs the lambda with it as the receiver, and returns the rendered string.

Step 3: Use it

fun main() {
    val page = html {
        head {
            title("My Page")
        }
        body {
            h1("Welcome")
            p("This is a paragraph.")
            p("Another paragraph.")
        }
    }
    println(page)
}
<html>
<head>
  <title>My Page</title>
</head>
<body>
  <h1>Welcome</h1>
  <p>This is a paragraph.</p>
  <p>Another paragraph.</p>
</body>
</html>

Inside the html { } block, this is an Html instance, so head() and body() are available. Inside body { }, this is a Body instance, so h1() and p() are available. The nesting is type-safe — you can’t call h1() inside head { }.

The @DslMarker annotation

There’s a subtle problem. Inside body { }, the receiver is Body. But Html is also available as an outer receiver. This means you could accidentally call head { } inside body { }:

html {
    body {
        h1("Title")
        head { // oops — this compiles but shouldn't be here
            title("Wrong place")
        }
    }
}

Fix this with @DslMarker:

@DslMarker
annotation class HtmlDsl

@HtmlDsl
class Html { /* ... */ }

@HtmlDsl
class Head { /* ... */ }

@HtmlDsl
class Body { /* ... */ }

Now calling head { } inside body { } won’t compile. @DslMarker prevents implicit access to outer receivers marked with the same annotation.

Practical: Config DSL

A more realistic example — a typed configuration builder:

@DslMarker
annotation class ConfigDsl

@ConfigDsl
class ServerConfig {
    var host: String = "localhost"
    var port: Int = 8080
    private var dbConfig: DatabaseConfig? = null
    private var authConfig: AuthConfig? = null

    fun database(block: DatabaseConfig.() -> Unit) {
        dbConfig = DatabaseConfig().apply(block)
    }

    fun auth(block: AuthConfig.() -> Unit) {
        authConfig = AuthConfig().apply(block)
    }

    fun build(): Config = Config(host, port, dbConfig, authConfig)
}

@ConfigDsl
class DatabaseConfig {
    var url: String = ""
    var username: String = ""
    var password: String = ""
    var maxPoolSize: Int = 10
}

@ConfigDsl
class AuthConfig {
    var jwtSecret: String = ""
    var tokenExpiry: Long = 3600
    var issuer: String = ""
}

data class Config(
    val host: String,
    val port: Int,
    val database: DatabaseConfig?,
    val auth: AuthConfig?
)

fun server(block: ServerConfig.() -> Unit): Config {
    return ServerConfig().apply(block).build()
}

Usage:

val config = server {
    host = "0.0.0.0"
    port = 9090

    database {
        url = "jdbc:postgresql://localhost:5432/mydb"
        username = "admin"
        password = "secret"
        maxPoolSize = 20
    }

    auth {
        jwtSecret = "my-256-bit-secret"
        tokenExpiry = 7200
        issuer = "my-app"
    }
}

Everything is type-safe. You get autocomplete inside each block. If you mistype maxPoolSise, the compiler catches it.

Practical: Test data builder

Building test fixtures is often verbose. A DSL makes it readable:

@DslMarker
annotation class TestDsl

@TestDsl
class UserBuilder {
    var id: Long = 1
    var name: String = "John Doe"
    var email: String = "[email protected]"
    var age: Int = 30
    var active: Boolean = true
    private val roles = mutableListOf<String>()

    fun roles(vararg roleNames: String) {
        roles.addAll(roleNames)
    }

    fun build() = User(id, name, email, age, active, roles.toList())
}

fun user(block: UserBuilder.() -> Unit = {}): User {
    return UserBuilder().apply(block).build()
}

Usage in tests:

@Test
fun `inactive users are excluded from search`() {
    val activeUser = user {
        name = "Alice"
        active = true
    }

    val inactiveUser = user {
        name = "Bob"
        active = false
    }

    val result = searchService.findActive(listOf(activeUser, inactiveUser))

    assertEquals(1, result.size)
    assertEquals("Alice", result[0].name)
}

Default values mean you only specify what’s relevant to the test. The DSL version is clearer than a constructor with 6 parameters.

The apply pattern

You’ve seen apply(block) in the examples above. This is the standard way to set up a builder:

fun server(block: ServerConfig.() -> Unit): Config {
    return ServerConfig().apply(block).build()
}

apply runs the lambda with the object as receiver and returns the object. It’s essentially:

fun server(block: ServerConfig.() -> Unit): Config {
    val config = ServerConfig()
    config.block()  // same as block(config)
    return config.build()
}

Use apply when you want concise builder functions. Both forms are equivalent.

Builder with validation

Add validation to catch configuration errors at build time:

@ConfigDsl
class DatabaseConfigBuilder {
    var url: String = ""
    var username: String = ""
    var password: String = ""
    var maxPoolSize: Int = 10

    fun build(): DatabaseConfig {
        require(url.isNotBlank()) { "Database URL must not be blank" }
        require(username.isNotBlank()) { "Database username must not be blank" }
        require(maxPoolSize in 1..100) { "Pool size must be between 1 and 100" }
        return DatabaseConfig(url, username, password, maxPoolSize)
    }
}

Now if someone forgets to set the URL:

val config = server {
    database {
        username = "admin"
        // forgot url
    }
}
// throws IllegalArgumentException: Database URL must not be blank

Fail fast, with a clear message.

Operator overloading in DSLs

You can use operator overloading to make DSLs even more expressive:

class RouteBuilder {
    private val routes = mutableListOf<Route>()

    operator fun String.invoke(block: RouteHandler.() -> Unit) {
        routes.add(Route(this, RouteHandler().apply(block)))
    }

    fun build(): List<Route> = routes.toList()
}

// Usage
routing {
    "/users" {
        get { /* handle GET /users */ }
        post { /* handle POST /users */ }
    }
    "/health" {
        get { /* handle GET /health */ }
    }
}

The String.invoke extension lets you use a string directly as a function call. This is the kind of thing that makes Ktor’s routing DSL work.

Use this sparingly — operator overloading can make code harder to understand if the meaning isn’t obvious.

When to build a DSL

DSLs are worth it when:

  • The same configuration pattern repeats across the codebase
  • The configuration has a natural hierarchical structure
  • You want IDE autocomplete and compile-time safety
  • The alternative is stringly-typed maps or verbose builder chains

DSLs are overkill when:

  • A simple function with parameters does the job
  • The configuration is flat (no nesting)
  • Only used in one or two places

The best DSLs feel obvious to use. If someone needs to read documentation to understand the syntax, it’s probably too clever.

Summary

ConceptPurpose
Lambda with receiverLets you call methods on this inside a lambda
@DslMarkerPrevents leaking outer receivers into nested blocks
apply(block)Runs a lambda with receiver and returns the object
Builder + build()Validates and converts mutable builder to immutable result

Kotlin DSLs are built on simple pieces — lambdas with receivers, extension functions, and the builder pattern. Once you see the pattern, you’ll recognize it everywhere: Gradle, Ktor, Compose, Exposed, Kotest. And you can build your own whenever a declarative API makes your code clearer.