Kotlin DSLs Are Just Lambdas With Receivers — Here's the Proof
This article is translated from 'Kotlin DSL Deep Dive: From Gradle Scripts to Building Your Own', original link https://proandroiddev.com/kotlin-dsl-deep-dive-from-gradle-scripts-to-building-your-own-8e08a1471467, published by Ioannis Anifantakis on July 2, 2026.
Introduction
You must have seen the term "Kotlin DSL" everywhere. It exists in your Gradle files, in discussions about Jetpack Compose, and in every Ktor tutorial. If you've ever asked "Okay, but what exactly is it?", you've likely gotten only two types of answers: either a dictionary definition ("Domain-Specific Language!") that explains nothing, or a large block of code that assumes you already know.
This article will fill that gap. After reading this, you will thoroughly understand what a Kotlin DSL is, what those .kts files are, and why Gradle uses them; why code like dependencies { implementation("...") } is actually ordinary Kotlin code dressed in Kotlin's clothing; and how to build your own DSL (a usable HTML builder) line by line.
Before we start, let's introduce another abbreviation. If you've worked in Android development before, you've definitely encountered KTX (core-ktx, fragment-ktx...). KTX is not a Kotlin DSL, nor does it have any relation to .kts files, even though they all start with the letter K. KTX libraries are extension functions used to optimize single calls in Android; whereas a DSL constructs an entire nested vocabulary. The two are fundamentally the same, yet fundamentally different. Part 2 will elaborate on both.
No DSL knowledge is required. Let's settle this once and for all.
Related GitHub Repository:
https://github.com/ioannisa/HtmlBuilder-Kotlin-DSL-Demo
Important Note:
All code in this article is located in the companion project linked above.
This is an Android application that renders the DSL's output in a live WebView and provides an interactive tab where you can add products and see the loop re-rendering from Part 5.
The DSL itself resides in a pure Kotlin file with no Android imports, and unit tests verify every output printed below verbatim.
Part 1: What "DSL" Really Means
A Domain-Specific Language is a language designed for a specific task, as opposed to a general-purpose language suitable for any job.
You use Domain-Specific Languages (DSLs) every day:
SQL is a DSL for querying data. You can't build a mobile app with it, but for a query like "find all users over 30 years old," it's unbeatable.
Regular Expressions are a DSL for text pattern matching.
CSS is a DSL for describing visual styles.
HTML is a DSL for describing document structure.
None of these languages can be as efficient as Kotlin or Java. That's precisely the point. A DSL, by giving up generality, can exhibit more powerful expressiveness within its domain. SELECT * FROM users WHERE age > 30 is almost comparable to English. The equivalent statement in a general-purpose language requires loops, conditionals, and accumulator lists. More powerful, but also more cumbersome.
External DSLs vs. Internal DSLs
Everything in this article revolves around this distinction.
External DSLs are standalone languages. SQL, Regular Expressions, and CSS each have their own syntax, parser, and rules. Someone specifically wrote a compiler or interpreter for them. Building an external DSL is a massive engineering undertaking.
Internal DSLs (also known as embedded DSLs) take a shortcut: instead of inventing a new language, they reshape an existing general-purpose language until valid code written in that language looks like code written in a specialized language. No new parser is needed, no new compiler. Every line of code is ordinary code—it just looks different.
When people say "Kotlin DSL," they are referring to an internal DSL written in Kotlin. This alone unravels most of the mystery:
A Kotlin DSL is not a new language. It is essentially ordinary Kotlin code, just using some language features to make it read like a configuration format.
This leads to an obvious question: What exactly are these features?
Part 2: The Five Features That Make Kotlin a DSL Machine
Some languages are good hosts for internal DSLs; some are terrible. Kotlin happens to be one of the best, and this is no accident - JetBrains designed these features with DSLs explicitly in mind. There are five of them, and the fifth is the one that carries the weight.
Feature 1: Trailing Lambda Syntax
In Kotlin, if the last argument of a function is a lambda, you can move it outside the parentheses:
// These are the same call:
repeat(3, {
println("Hi")
})
repeat(3) {
println("Hi")
}
If the lambda is the only argument, you can drop the parentheses entirely:
// These are the same call:
run({
println("Hi")
})
run {
println("Hi")
}
Pause on that second example. run { ... } does not look like a function call. It looks like a language keyword. Like if or while with a block. This illusion is the foundation of every Kotlin DSL. When you see the following in a Gradle file:
dependencies {
// ...
}
…you are looking at a function named dependencies being called with a single lambda argument. That's it. Half the mystery of Gradle syntax is solved.
Feature 2: Extension Functions
Kotlin allows you to add functions to types you don't own:
fun String.shout() = this.uppercase() + "!!!"
"hello".shout() // "HELLO!!!"
String is a final class from the standard library, but we just gave it a new method. (Under the hood, it compiles to a static function taking the string as a parameter, so no class is actually modified.) For DSLs, this means you can attach domain vocabulary to any type, including types from libraries you don't control.
If you're an Android developer, you already have a whole bunch of these: KTX libraries (core-ktx, fragment-ktx, etc.) are essentially the productization of this feature; extension functions that wrap Java Android APIs in idiomatic Kotlin, so you can write view.isVisible = true instead of view.visibility = View.VISIBLE.
KTX and DSLs are close relatives from the same toolbox, but with different goals:
- KTX makes single calls nicer;
- DSLs use receivers (Feature 5, coming up) to build nested structures.
The line blurs in a few places—sharedPreferences.edit { putString("key", value) } from core-ktx is a lambda with a receiver, a single-block mini-DSL.
To clear up the confusion this article aims to resolve: KTX has nothing to do with **.kts** — one is a library naming convention, the other is the file extension for Kotlin scripts, which we'll cover in Part 3.
Feature 3: Infix Functions
Functions marked infix can be called without dots and parentheses:
infix fun Int.times(str: String) = str.repeat(this)
3 times "Hey " // "Hey Hey Hey "
You've used this without noticing: to in mapOf("a" to 1) is not syntax. It's an infix extension function on a generic type that returns a Pair.
Testing frameworks rely heavily on this. Kotest's result shouldBe 42 is an infix function call.
Feature 4: Operator Overloading
Kotlin lets you define what +, -, [], (), and friends mean for your types via functions with the operator keyword:
operator fun File.plus(other: File): File = File(this, other.name)
Gradle's Kotlin DSL uses this in places like sourceSets["main"]. That bracket access is an operator fun get(name: String) call on a collection-like container.
Feature 5: Lambda with Receiver — The Big One
This is the feature. If you understand this section, you understand Kotlin DSLs. Read it twice if needed; everything after depends on it.
A normal lambda with a parameter looks like this:
val greet: (StringBuilder) -> Unit = { sb ->
sb.append("Hello")
sb.append(", world")
}
Inside the lambda, we must do everything through the parameter sb (or _it__ if you didn't name the parameter)_. Now look at the lambda-with-receiver version:
val greet: StringBuilder.() -> Unit = {
append("Hello")
append(", world")
}
Two things changed:
- The type changed from
(StringBuilder) -> UnittoStringBuilder.() -> Unit. Read this as "a function on StringBuilder" rather than "a function taking a StringBuilder". An anonymous extension function. - Inside the lambda, the parameter disappeared. We call
append(...)directly instead ofsb.append(...)
Why does this work? Because inside a lambda with receiver, **this** is the receiver object. The lambda's body executes as if it were a method of StringBuilder. Just like you don't write this.append() inside a class's own method, you don't write it here. The receiver is implicit.
Now combine this with trailing lambda syntax.
Here is a tiny but complete DSL-style function. This is the pattern the standard library's buildString uses:
// DSL Definition
fun buildString(builderAction: StringBuilder.() -> Unit): String {
val sb = StringBuilder()
sb.builderAction() // run the lambda with sb as the receiver
return sb.toString()
}
// Call site
val message = buildString {
append("Hello")
append(", world")
}
Look at the call site. A block where you can "just say" append(...) and it works. No object in sight, no dots, no boilerplate. It feels like a special mini-language for string building. It's three lines of ordinary Kotlin.
This is the whole trick._ Every Kotlin DSL you've ever seen (Gradle, Ktor's routing, kotlinx.html) is some permutation of functions that accept lambdas with receivers, so that each
_{ }_block changes what_this_is, and therefore what functions you can "just say".Jetpack Compose layers on top*. A compiler plugin, recomposition, the*`@Composable`machinery. But its scoped blocks, like`RowScope`deciding that`weight()`is legal, are exactly this receiver trick.
And you've been using it without the DSL label all along:
The standard library's scope functions (apply, run, with) are micro-DSLs built on nothing else. person.apply { name = "Ioannis" } is just a lambda with person as the receiver.
Nested blocks are just nested receivers:
html { // 'this' is an Html object → you can call body()
body { // 'this' is a Body object → you can call p()
p { // 'this' is a P object → you can call text fns
+"Hello"
}
}
}
Each level of nesting swaps in a new implicit receiver, and therefore a new vocabulary. This is how DSLs get their structure: the type system itself dictates what is allowed where.
Write
_body { body { } }_and it won't compile._Body_has no_body()_function. Your "language" has grammar rules, enforced by the compiler, for free.
We will build exactly this HTML DSL in Part 5. But first, let's use our new knowledge on the files that brought you here.
Part 3: What ".kts" Files Actually Are
Time to demystify the file extension.
**.kt** is a Kotlin source file. It contains declarations (classes, functions, properties) and is compiled as part of a module: an application, a library, a test suite, a Gradle plugin.
A **.kt** file never runs on its own; it becomes part of something that is built, and if that something is an executable program, it needs an entry point like main().
**.kts** is a Kotlin script. The "s" stands for script. A script file can contain top-level executable statements. Code that just runs from top to bottom, no main() required:
// hello.kts — this whole file is valid as-is
val name = "Ioannis"
println("Hello, $name")
println("Today is ${java.time.LocalDate.now()}")
If the Kotlin compiler is installed, you can run it directly:
kotlinc -script hello.kts
Think of
**_.kts_**as Kotlin's answer to shell scripts or Python files: a file you execute, not a file you build into something.Kotlin scripts also allow host applications to embed and run scripts, controlling what those scripts can see and do... which brings us to Gradle.
So What Is build.gradle.kts?
Gradle is a build tool, and your build configuration needs to be expressive: conditionals, loops, variables, logic.
So instead of a static format like JSON or XML, Gradle makes its configuration files executable scripts. For years, these were written in Groovy (build.gradle); since Gradle 5.0, you can write them in Kotlin instead—build.gradle.kts, a Kotlin script that Gradle compiles and executes when configuring your build.
Here is the part almost no tutorial says out loud:
_build.gradle.kts_is a Kotlin script that executes with the Gradle**_Project_**_object as its implicit receiver _.
The entire file behaves like one giant lambda with a receiver; the exact same mechanism from Part 2, applied at file scale. Inside build.gradle.kts, this is (effectively) a Project. This means every "magic keyword" in that file is just a member or extension of Project:
plugins { // a function on Project, taking a lambda
kotlin("android") // a function returning a plugin spec
}
android { // extension function added by the Android plugin
compileSdk = 36 // property on the AndroidExtension receiver
defaultConfig { // another nested lambda with receiver
minSdk = 24
}
}
dependencies { // Project.dependencies(config: DependencyHandlerScope.() -> Unit)
implementation("io.ktor:ktor-client-core:3.0.0")
}
Walk through **dependencies** with your new eyes: it's a function call with a trailing lambda.
The lambda's receiver is DependencyHandlerScope. implementation(...) is a function available on that receiver. Three features from Part 2 (trailing lambda, extension functions, lambda with receiver), and the whole file is a lot less magical.
This also explains things that confuse people about the Gradle Kotlin DSL:
- Why does autocomplete work in
**.kts**but not in Groovy**build.gradle**? Because Kotlin scripts are statically typed. The IDE knowsthisis aProject, knows whatandroid { }exposes, and can offer completions and catch typos at edit time. Groovy resolves most things dynamically at runtime, so the IDE is mostly guessing. - Why do typos fail before the build even runs? Same reason—the script is compiled. A misspelled property is a compilation error, not a runtime surprise.
- What is
**settings.gradle.kts**then? Same idea with a different receiver: aSettingsobject instead ofProject. Different receiver, different vocabulary—that's whyinclude(":app")works butdependencies { }does not. The receiver is the language.
An honest warning: the first build with the Kotlin DSL can be slower than Groovy because the script is compiled, and heavy use of buildSrc can hurt incremental builds. For most projects, the IDE support is worth it, and it has been Gradle's default for new projects since 2023.
Who Defines the Vocabulary? Gradle vs. Plugins (and Where AGP Fits)
A common point of confusion, especially for Android developers:
- You have a Gradle version (in
gradle-wrapper.properties), - An AGP version (in
libs.versions.toml), - And they are different numbers. So who is responsible for the DSL?
The answer is a clear division of labor:
- Gradle itself ships the Kotlin DSL: the machinery that compiles
.ktsscripts, the implicitProjectreceiver, and the core vocabulary:plugins { },dependencies { },repositories { },tasks { }. This is all tied to your Gradle version. - Plugins extend the DSL with new vocabulary. The Android Gradle Plugin does not define the Kotlin DSL; it speaks it. When you apply
com.android.application, AGP registers an extension object on yourProject, and that registration is what makes theandroid { }block exist. Everything inside it (compileSdk,defaultConfig { },buildTypes { }) is the API of AGP's extension class, which becomes the receiver for that block. Remove the plugin, andandroid { }becomes a compilation error because the function literally no longer exists. The Kotlin Gradle plugin addskotlin { }the same way. **libs.versions.toml**defines neither. The version catalog is a plain TOML file (a static format, not a DSL) whose sole job is to pin the versions of the plugins and libraries you load. (Gradle then generates the typedlibs.versions.agp-style accessors you use in your scripts.)
In summary: Gradle provides the language, plugins provide the vocabulary, and the catalog pins the versions.
This is also why the IDE only autocompletes
_android { }_after syncing with the applied plugins; the receiver type comes from AGP's jar, not from Gradle.This is why the two version numbers move independently but not arbitrarily: each AGP version requires a minimum Gradle version (a compatibility matrix in Google's docs).
If anything, the design of the Kotlin DSL pays off here: Gradle's build language can be extended by third parties without Gradle needing to know anything about Android. Any plugin author can use the receiver trick from Part 2 to add typed, autocompleted blocks to your build scripts, exactly what you will do for your own domain in Part 5.
The Pattern, Everywhere
Once you see "function + trailing lambda + receiver," you can't unsee it.
Ktor server setup:
embeddedServer(Netty, port = 8080) {
routing { // receiver: Routing
get("/hello") { // receiver: RoutingContext
call.respondText("Hi!")
}
}
}.start(wait = true)
Coroutine scopes (launch { }, withContext { }), kotlinx.serialization configuration, Koin's module { }, Compose's layout blocks. Different domains, same machinery. You can now read all of them fluently.
Part 4: Dissecting a DSL Before Building One
Building a DSL is a design exercise before it's a coding exercise. The recipe:
- Decide what the call site should look like. Write the dream code first, as if the DSL already exists.
- Identify the receivers. Every
{ }block in your dream code needs a class. The members of that class define what is legal inside the block. - Write builder functions that create the receiver object, apply the user's lambda to it, and register the result with the parent.
- Add guardrails (
@DslMarker- coming up) so the compiler enforces your grammar.
Our dream code: the HTML builder we promised:
val page = html {
head {
title { +"My Page" }
}
body {
h1 { +"Welcome" }
p {
+"This entire page is "
b { +"plain Kotlin" }
+"."
}
}
}
println(page.render())
We need to invent three things:
- Tag classes (the receivers),
- Builder functions (
html,body,p...), - And that strange
+"text"syntax.
Let's go.
Part 5: Building the HTML DSL Step by Step
Step 1: Model the Domain
Everything in an HTML document is a node: either a text node or an element (a tag containing other nodes). Classic composite pattern:
interface Node {
fun render(indent: String = ""): String
}
class TextNode(private val text: String) : Node {
override fun render(indent: String) = "$indent$text\n"
}
open class Tag(private val name: String) : Node {
private val children = mutableListOf<Node>()
fun addChild(node: Node) {
children.add(node)
}
override fun render(indent: String): String = buildString {
append("$indent<$name>\n")
children.forEach { append(it.render("$indent ")) }
append("$indent</$name>\n")
}
}
Note the small test moment: render uses buildString; the lambda-with-receiver function we dissected in Part 2.
No DSL yet. Using it directly is exactly the boilerplate we want to escape:
val page = Tag("html")
val body = Tag("body")
val p = Tag("p")
p.addChild(TextNode("Hello"))
body.addChild(p)
page.addChild(body)
Functional, but the page's structure (what is inside what) is invisible. You have to track variable names to reconstruct the tree. The DSL's job is to make the shape of the code match the shape of the document.
Step 2: The Core Builder Trick
This is the heart of every builder DSL, a universal helper:
class Html : Tag("html")
class Head : Tag("head")
class Body : Tag("body")
private fun <T : Tag> Tag.initTag(tag: T, init: T.() -> Unit): T {
tag.init() // run the user's block with the new tag as receiver
addChild(tag) // attach it to the parent (this)
return tag
}
fun html(init: Html.() -> Unit): Html {
val html = Html()
html.init()
return html
}
fun Html.head(init: Head.() -> Unit) = initTag(Head(), init)
fun Html.body(init: Body.() -> Unit) = initTag(Body(), init)
Read initTag slowly. It's three lines, and it's the whole engine:
init: T.() -> Unit: the user's{ }block, typed as a lambda whose receiver is the new tag. Inside that block,thiswill be the newly created tag, so calls inside the block apply to it.tag.init(): executes the user's block on the new tag. Any children that block creates are added totag.addChild(tag): hooks the finished tag into the parent. BecauseinitTagis an extension onTag, the parent is the current receiver—whichever block we are lexically inside.
That last point is worth repeating: nesting works because at any point in the DSL, the "parent" is simply the receiver of the enclosing block. The nesting of lambdas is the nesting of the document. No stack to manage, no global "current node"—Kotlin's scoping does the bookkeeping.
Also note what head and body are: extension functions on Html. That's a grammar rule. You can only call body { } where this is Html, i.e., directly inside an html { } block. Try putting body inside a p, and the compiler rejects it. We encoded an HTML rule into the type system.
Step 3: Content Tags and the + Trick
class Title : Tag("title")
class H1 : Tag("h1")
class P : Tag("p")
class B : Tag("b")
fun Head.title(init: Title.() -> Unit) = initTag(Title(), init)
fun Body.h1(init: H1.() -> Unit) = initTag(H1(), init)
fun Body.p(init: P.() -> Unit) = initTag(P(), init)
fun P.b(init: B.() -> Unit) = initTag(B(), init)
Now, text. We want to write +"Welcome" inside a tag and have it become a text node. This is operator overloading (Feature 4). We define unary plus on String, as a member of Tag:
open class Tag(private val name: String) : Node {
// ...everything from before...
operator fun String.unaryPlus() {
addChild(TextNode(this))
}
}
This declaration is worth unpacking because it uses a subtle Kotlin feature: it's an extension function on String declared inside Tag, so it has two receivers. this is the string; the enclosing Tag is also in scope (as this@Tag, which is what the bare addChild resolves to).
The effect: +"Welcome" compiles only inside a tag block and adds the text to that tag. Outside a DSL, +"some string" remains the compilation error it should be.
Why **+** at all? Because a bare string expression like "Welcome" on its own line is valid Kotlin that does nothing. It evaluates and is discarded, and the compiler won't complain. The DSL needs some token to mean "this string is content, add it." Unary plus is the lightest-weight token available. (kotlinx.html, the JetBrains library this design mirrors, makes the same choice.)
Step 4: It Works
fun main() {
val page = html {
head {
title { +"My Page" }
}
body {
h1 { +"Welcome" }
p {
+"This entire page is "
b { +"plain Kotlin" }
+"."
}
}
}
print(page.render())
}
Output:
<html>
<head>
<title>
My Page
</title>
</head>
<body>
<h1>
Welcome
</h1>
<p>
This entire page is
<b>
plain Kotlin
</b>
.
</p>
</body>
</html>
In roughly 60 lines of plain Kotlin, we have a typed, IDE-completing, compiler-checked language for writing HTML. A tag misspelled: compilation error. A tag placed where it doesn't belong: compilation error. Autocomplete inside any block shows exactly (and only) what is legal there.
And because the DSL is Kotlin, all of Kotlin works inside it. This is the superpower that external formats like XML and JSON can never have:
val products = listOf("Keyboard", "Mouse", "Monitor")
val page = html {
body {
h1 { +"Products" }
for (product in products) {
p { +product }
}
}
}
Loops inside the markup, no template engine required. Conditionals, functions, early returns—all free, because there is no boundary to cross. It was never not Kotlin.
Step 5: Guardrails — @DslMarker
One flaw remains. Inside a nested block, the outer receivers are still in scope. This means this compiles:
html {
body {
p {
body { } // ouch — resolves against the outer Html receiver
}
}
}
Inside p { }, Kotlin can't find body() on P, so it walks outward through the enclosing receivers, finds Html up the chain, and helpfully resolves the call there. The result is body silently nested inside html twice. The grammar we built has a leak.
Kotlin's fix was built specifically for DSLs:
@DslMarker
annotation class HtmlDsl
@HtmlDsl
open class Tag(private val name: String) : Node { /* ... */ }
Define an annotation, mark it with @DslMarker, apply it to your receiver classes (annotating the base class covers all subclasses). The rule it enforces: when two implicit receivers carry the same DSL marker, only the innermost one is accessible. The body { } call inside p { } now fails to compile with a clear error. The leak is sealed.
If you've ever wondered why Compose has @ComposableTargetMarker-style scoping, or why kotlinx.html won't let you write a div inside the text of a span, this annotation, or this idea, is why. One line of annotation, and your DSL grammar is airtight.
Part 6: Should You Build a DSL?
Now that you can, the harder question is: should you?
Use a DSL when you are describing structured, nested, declarative things: configuration, UI trees, markup, routing tables, test scenarios, query builders. The pattern shines when the shape of the code can mirror the shape of the data, when the type system can enforce "what goes where," and when many people will read or write these descriptions often. This is exactly why Gradle, Compose, and Ktor camp here.
Skip a DSL when the logic is sequential rather than structural (a plain function is clearer), when there is only one call site (the abstraction costs more than it saves), or when you are tempted purely by aesthetics. A DSL is an API with opinions—every opinion is something the next developer must learn. The questions "Where is this function defined?" and "What is this right now?" become harder to answer inside deeply nested receiver scopes; good DSLs keep nesting shallow and vocabularies small. Like any clever API, a DSL's growth turns into a maze without design discipline: the magic at the call site means indirection underneath. Debugging inherits this indirection too—when something fails inside a DSL, the stack trace often points inside the builder rather than at the call site that caused the problem, so invest in good error messages early.
A reasonable rule of thumb: a DSL must be written many times by many people, or read far more often than its implementation—otherwise write boring code.
TL;DR — The Whole Article in Six Lines
- A DSL is a mini-language for one domain; a Kotlin DSL is an internal one — ordinary Kotlin styled to read like a configuration format. No new parser, no new language.
- The illusion is built by five features: trailing lambdas, extension functions, infix functions, operator overloading, and most critically lambdas with receivers — each
{ }block swaps in a new implicitthis, which swaps in a new vocabulary. **.kts**= Kotlin script: a file executed top-to-bottom, nomain()required.**build.gradle.kts**is a Kotlin script whose implicit receiver is the GradleProject— sodependencies { }is just a function call with a trailing lambda, which is why the IDE can autocomplete it.- To build your own: design the dream call site, give each block a receiver class, wire them together with
init: T.() -> Unitbuilder functions, and seal scope leaks with**@DslMarker**. - Build one when structure, repetition, and readability all justify it. Otherwise, write boring code—boring code is underrated.
The next time someone on your team mutters "What is this Gradle syntax," you know exactly what to tell them: it was always Kotlin.
Welcome to search and follow the public account 「稀有猿诉」 for more high-quality articles!
Protect originality, do not repost!