07 · DSLs in Kotlin¶
Gradle's build.gradle.kts, Ktor's routing { get("/x") { } } from
Module 2, and Exposed's transaction { }
from Module 3 all look like
special syntax, but they're plain Kotlin functions taking lambdas. This
module covers the language features that make that possible: lambdas with
receivers, infix functions, and @DslMarker.
Lambda with receiver: the core trick¶
A regular lambda parameter (Tag) -> Unit requires calling methods on an
explicit parameter ({ tag -> tag.text = "hi" }). A lambda with
receiver, Tag.() -> Unit, runs the lambda body as if it were a member
of Tag — so this (or an implicit receiver) refers to the Tag
directly, letting the block read like nested markup.
class Tag(val name: String) {
val children = mutableListOf<Tag>()
val attributes = mutableMapOf<String, String>()
var text: String = ""
fun render(indent: String = ""): String {
val attrs = if (attributes.isEmpty()) "" else " " + attributes.entries.joinToString(" ") { "${it.key}=\"${it.value}\"" }
return if (children.isEmpty()) {
"$indent<$name$attrs>$text</$name>"
} else {
val inner = children.joinToString("\n") { it.render("$indent ") }
"$indent<$name$attrs>\n$inner\n$indent</$name>"
}
}
}
fun Tag.tag(name: String, block: Tag.() -> Unit): Tag {
val child = Tag(name)
child.block()
children.add(child)
return child
}
fun html(block: Tag.() -> Unit): Tag {
val root = Tag("html")
root.block()
return root
}
fun main() {
val page = html {
tag("head") {
tag("title") { text = "My Page" }
}
tag("body") {
attributes["class"] = "main"
tag("h1") { text = "Welcome" }
tag("p") { text = "Hello, DSL!" }
}
}
println(page.render())
}
<html>
<head>
<title>My Page</title>
</head>
<body class="main">
<h1>Welcome</h1>
<p>Hello, DSL!</p>
</body>
</html>
Inside tag("body") { attributes["class"] = "main"; tag("h1") { ... } },
attributes and the nested tag(...) call both resolve against the
implicit Tag receiver — no it. or explicit parameter name needed. This
pattern (a function taking a Receiver.() -> Unit lambda, called "type-safe
builder") is exactly what routing { }, transaction { }, and Gradle's
dependencies { } are built from.
Infix functions and operator overloading¶
infix lets a single-parameter function be called without the dot or
parentheses, which reads naturally for small assertion- or config-style
APIs.
class Requirement(val name: String) {
val rules = mutableListOf<String>()
infix fun mustBe(value: String) { rules.add("$name must be $value") }
}
fun main() {
val r = Requirement("status")
r mustBe "active" // instead of r.mustBe("active")
println(r.rules)
}
Kotlin's own to (building a Pair), and/or (on Boolean), and
../rangeTo are all infix or operator functions defined in the standard
library the same way — there's no special compiler magic beyond what's
available to any library author.
@DslMarker: preventing scope confusion¶
Nested builder lambdas each bring their own implicit receiver into scope.
Without help, an inner block could accidentally call a method meant for an
outer receiver, producing a DSL call in the wrong place with no compile
error. @DslMarker on an annotation, applied to each builder class, tells
the compiler to only expose the nearest enclosing receiver implicitly.
@DslMarker
annotation class SpecDsl
@SpecDsl
class Requirement(val name: String) {
val rules = mutableListOf<String>()
infix fun mustBe(value: String) { rules.add("$name must be $value") }
}
@SpecDsl
class Spec {
val requirements = mutableListOf<Requirement>()
fun requirement(name: String, block: Requirement.() -> Unit) {
val r = Requirement(name)
r.block()
requirements.add(r)
}
}
fun spec(block: Spec.() -> Unit): Spec {
val s = Spec()
s.block()
return s
}
fun main() {
val s = spec {
requirement("status") {
this mustBe "active"
// requirement("nested") { } -- would NOT compile here
}
}
println(s.requirements.size)
}
Uncommenting requirement("nested") { } inside the Requirement block
produces:
error: 'fun requirement(name: String, block: Requirement.() -> Unit): Unit'
cannot be called in this context with an implicit receiver. Use an
explicit receiver if necessary.
Without @SpecDsl on both classes, that call would silently compile —
Spec.requirement is technically still reachable through the outer
receiver — and you'd get a Requirement nested inside another
Requirement's block, which makes no structural sense for this DSL.
@DslMarker turns that mistake into a compile error instead of a
confusing runtime structure.
Kotlin-specific traps¶
- A receiver lambda's
thisshadows outer receivers one level at a time, not all at once — without@DslMarker, every enclosing receiver's members stay implicitly callable inside a deeply nested block, which is the exact bug@DslMarkerprevents. infixfunctions must have exactly one parameter, no default value, and can't be vararg — trying to add a second required parameter is a compile error, forcing infix APIs to stay genuinely binary-operator-like.- Type-safe builders return
Unit-typed lambdas by convention, but the outer function (html { },spec { }) usually returns the built object — mixing these up (returning the lambda's result instead of the accumulated builder) is a common bug when first writing a DSL. - Operator overloading can make code less readable if overused.
Kotlin lets you overload
+,*,..,[], and more on your own types, but an overload whose behavior doesn't match the operator's common meaning (e.g.+that doesn't commute) actively misleads readers — this is a taste/API-design trap, not a compiler-enforced one.
How It Actually Works¶
A lambda with receiver, Tag.() -> Unit, compiles to
kotlin.jvm.functions.Function1<Tag, Unit> — the exact same Function1
interface as a plain (Tag) -> Unit lambda; there is no separate
ReceiverFunction type at the bytecode level. The only difference lives in
how the call site invokes it: child.block() for a lambda-with-receiver
compiles to block.invoke(child), passing the receiver as the sole
argument, which is indistinguishable in bytecode from calling a regular
one-parameter lambda with child as its parameter. What actually changes
is purely how the compiler resolves unqualified names inside the lambda
body — for Tag.() -> Unit, name resolution inside the lambda first
checks members of Tag before falling back to the enclosing scope, which
is why attributes["class"] = "main" inside tag("body") { } resolves to
Tag.attributes without needing this. — that resolution rule is a
compile-time-only convenience; by the time it's bytecode, it's just a method
call on an object that happened to be passed as an argument.
infix fun mustBe(...) similarly changes nothing about the generated method
— "age" mustBe "18" and "age".mustBe("18") compile to the identical
invokevirtual/invokestatic call; infix only relaxes the parser's
grammar to accept the dot-less, no-parens call form for functions meeting
its constraints (exactly one parameter, no default, no vararg). @DslMarker
is even more purely compile-time: it's an annotation Kotlin's type-checker
consults to restrict which implicit receivers are considered "in scope"
when resolving a name at a given nesting depth, deliberately narrowing the
resolution rule described above — it emits no bytecode of its own at all
and has zero runtime footprint; you could strip every @DslMarker
annotation after compilation succeeds and the resulting .class files would
behave identically, because it only ever influenced whether compilation was
allowed to proceed in the first place.
Cheat sheet¶
| Concept | Syntax | Purpose |
|---|---|---|
| Lambda with receiver | block: Receiver.() -> Unit |
this/implicit receiver inside the lambda |
| Type-safe builder | fun receiver(block: T.() -> Unit): T |
The core DSL-building pattern |
| Infix function | infix fun T.name(x: X) |
Operator-like call syntax: a name b |
| Operator overload | operator fun T.plus(x: X) |
Reuse +, -, [], etc. on custom types |
@DslMarker |
Annotation on an annotation, applied to builder classes | Restricts implicit receivers to the nearest scope |
Exercise¶
Extend the html DSL with a ul/li pair: tag("ul") { tag("li") {
text = "One" }; tag("li") { text = "Two" } } should already work with the
existing tag() function — verify it renders correctly. Then add a
dedicated Tag.li(text: String) extension function so callers can write
li("One") instead of tag("li") { text = "One" }, and re-render the page
using it.