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
| Concept | Purpose |
|---|---|
| Lambda with receiver | Lets you call methods on this inside a lambda |
@DslMarker | Prevents 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.