Table of contents
- Why Does Kotlin Read So Well
- Lambdas with Receivers
- @DslMarker
- infix Functions
- Operator Overloading
- Type Aliases
- DSL in Practice
Why Does Kotlin Read So Well
Kotlin code often “reads like a sentence.” Gradle build scripts, Ktor routing, Compose UI declarations — their natural feel isn’t a coincidence. It’s because Kotlin provides several syntactic mechanisms for building DSLs (Domain-Specific Languages).
This part breaks down those mechanisms one by one.
Lambdas with Receivers
In Part 5, we saw apply { }. The ability to access an object with this inside the block is thanks to a feature called lambda with receiver.
Let’s compare it with a regular lambda.
// Regular lambda
val greet: (String) -> String = { name -> "Hello, $name!" }
// Lambda with receiver
val greetWithReceiver: String.() -> String = { "Hello, $this!" }
fun main() {
println(greet("Kotlin")) // Hello, Kotlin!
println("Kotlin".greetWithReceiver()) // Hello, Kotlin!
}
String.() -> String is the type “a function that takes String as a receiver and returns String.” Inside the block, this refers to the receiver object, so it can be called as if it were a method of String.
When used as a function parameter, this creates a powerful builder pattern.
class HtmlBuilder {
private val elements = mutableListOf<String>()
fun h1(text: String) {
elements.add("<h1>$text</h1>")
}
fun p(text: String) {
elements.add("<p>$text</p>")
}
fun ul(block: UlBuilder.() -> Unit) {
val builder = UlBuilder()
builder.block()
elements.add(builder.build())
}
fun build(): String = elements.joinToString("\n")
}
class UlBuilder {
private val items = mutableListOf<String>()
fun li(text: String) {
items.add(" <li>$text</li>")
}
fun build(): String = "<ul>\n${items.joinToString("\n")}\n</ul>"
}
fun html(block: HtmlBuilder.() -> Unit): String {
val builder = HtmlBuilder()
builder.block()
return builder.build()
}
fun main() {
val page = html {
h1("Kotlin DSL")
p("An HTML builder made with lambdas with receivers")
ul {
li("Concise")
li("Readable")
li("Type-safe")
}
}
println(page)
}
The output is:
<h1>Kotlin DSL</h1>
<p>An HTML builder made with lambdas with receivers</p>
<ul>
<li>Concise</li>
<li>Readable</li>
<li>Type-safe</li>
</ul>
Being able to call h1(), p(), and ul { } directly inside the html { } block is because HtmlBuilder is the receiver object. You can generate HTML in a type-safe way without XML or string concatenation.
Drawing the call flow as a sequence diagram shows how lambdas with receivers wire together the builders.
sequenceDiagram
participant User as User Code
participant Html as html { } function
participant HB as HtmlBuilder
participant UB as UlBuilder
User->>Html: block(HtmlBuilder.() -> Unit)
Html->>HB: Create HtmlBuilder()
Html->>HB: Execute builder.block()
HB->>HB: h1("...") / p("...")
HB->>UB: ul { ... } -> Create UlBuilder
UB->>UB: li("...")
UB-->>HB: Accumulate build() result
HB-->>Html: Final string from build()
Html-->>User: Return HTML
@DslMarker
When nesting DSLs, a problem arises where methods from the outer scope are visible inside the inner scope.
// This becomes possible
html {
ul {
h1("h1 here?") // The outer HtmlBuilder's h1 is visible
li("Item")
}
}
@DslMarker prevents this issue.
@DslMarker
annotation class HtmlDsl
@HtmlDsl
class SafeHtmlBuilder {
private val elements = mutableListOf<String>()
fun h1(text: String) { elements.add("<h1>$text</h1>") }
fun p(text: String) { elements.add("<p>$text</p>") }
fun ul(block: SafeUlBuilder.() -> Unit) {
val builder = SafeUlBuilder()
builder.block()
elements.add(builder.build())
}
fun build(): String = elements.joinToString("\n")
}
@HtmlDsl
class SafeUlBuilder {
private val items = mutableListOf<String>()
fun li(text: String) { items.add("<li>$text</li>") }
fun build(): String = "<ul>\n${items.joinToString("\n")}\n</ul>"
}
Classes sharing the same @HtmlDsl annotation can no longer implicitly access members of outer scopes. Mistakes are caught at compile time.
infix Functions
Removing the dot (.) and parentheses from function calls can create code that reads like an English sentence. The infix keyword makes this possible.
infix fun Int.power(exponent: Int): Long {
var result = 1L
repeat(exponent) { result *= this }
return result
}
infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)
// This is actually already defined in the Kotlin standard library
data class Time(val hours: Int, val minutes: Int) {
infix fun and(seconds: Int): String = "$hours:$minutes:$seconds"
}
fun main() {
println(2 power 10) // 1024
println(2.power(10)) // Same code
val pair = "name" to "Kotlin" // Pair("name", "Kotlin")
println(pair)
val time = Time(14, 30)
println(time and 45) // 14:30:45
}
The rules for infix functions are simple: it must be a member function or extension function, must have exactly one parameter, and must not have a default value.
to is the most representative infix function. The to used in mapOf("key" to "value") is exactly this.
Operator Overloading
In Kotlin, you can define functions corresponding to operators like +, -, [], () with the operator keyword.
data class Vector(val x: Double, val y: Double) {
operator fun plus(other: Vector) = Vector(x + other.x, y + other.y)
operator fun minus(other: Vector) = Vector(x - other.x, y - other.y)
operator fun times(scalar: Double) = Vector(x * scalar, y * scalar)
operator fun unaryMinus() = Vector(-x, -y)
}
fun main() {
val v1 = Vector(1.0, 2.0)
val v2 = Vector(3.0, 4.0)
println(v1 + v2) // Vector(x=4.0, y=6.0)
println(v2 - v1) // Vector(x=2.0, y=2.0)
println(v1 * 3.0) // Vector(x=3.0, y=6.0)
println(-v1) // Vector(x=-1.0, y=-2.0)
}
Each operator has a predetermined corresponding function name.
| Operator | Function Name |
|---|---|
a + b | a.plus(b) |
a - b | a.minus(b) |
a * b | a.times(b) |
a[i] | a.get(i) |
a[i] = v | a.set(i, v) |
a() | a.invoke() |
a in b | b.contains(a) |
invoke is particularly interesting. It lets you call an object as if it were a function.
class Validator(private val rules: List<(String) -> Boolean>) {
operator fun invoke(input: String): Boolean {
return rules.all { it(input) }
}
}
fun main() {
val passwordValidator = Validator(listOf(
{ it.length >= 8 },
{ it.any { c -> c.isDigit() } },
{ it.any { c -> c.isUpperCase() } }
))
println(passwordValidator("Kotlin123")) // true
println(passwordValidator("short")) // false
}
passwordValidator("Kotlin123") calls the object with function call syntax. Internally, passwordValidator.invoke("Kotlin123") is what’s invoked.
However, overusing operator overloading makes code harder to read. Using + for an operation that deviates from the intuition of “addition” creates confusion. Use it only when the meaning is self-evident.
Type Aliases
When you want to shorten long types, use typealias.
// When callback function types get long
typealias EventHandler = (event: String, data: Map<String, Any>) -> Unit
typealias UserCache = MutableMap<Long, Pair<String, Int>>
typealias Predicate<T> = (T) -> Boolean
fun processEvents(handler: EventHandler) {
handler("click", mapOf("x" to 100, "y" to 200))
}
fun <T> List<T>.filterWith(predicate: Predicate<T>): List<T> {
return this.filter(predicate)
}
fun main() {
processEvents { event, data ->
println("Event: $event, Data: $data")
}
val numbers = listOf(1, 2, 3, 4, 5)
val isEven: Predicate<Int> = { it % 2 == 0 }
println(numbers.filterWith(isEven)) // [2, 4]
}
typealias doesn’t create a new type — it gives an existing type a name. After compilation, it’s substituted back to the original type. It’s useful for giving meaningful names to complex generic or function types.
DSL in Practice
Combining the features covered so far lets you build a DSL like this.
// Test DSL
class TestSuite(val name: String) {
private val tests = mutableListOf<TestCase>()
fun test(description: String, block: () -> Unit) {
tests.add(TestCase(description, block))
}
fun run() {
println("=== $name ===")
tests.forEach { testCase ->
try {
testCase.block()
println(" PASS: ${testCase.description}")
} catch (e: AssertionError) {
println(" FAIL: ${testCase.description} — ${e.message}")
}
}
}
}
data class TestCase(val description: String, val block: () -> Unit)
infix fun <T> T.shouldBe(expected: T) {
if (this != expected) {
throw AssertionError("Expected $expected but got $this")
}
}
fun suite(name: String, block: TestSuite.() -> Unit): TestSuite {
val s = TestSuite(name)
s.block()
return s
}
fun main() {
val mathTests = suite("Math Tests") {
test("1 + 1 = 2") {
(1 + 1) shouldBe 2
}
test("2 * 3 = 6") {
(2 * 3) shouldBe 6
}
test("Intentional failure") {
(1 + 1) shouldBe 3
}
}
mathTests.run()
}
=== Math Tests ===
PASS: 1 + 1 = 2
PASS: 2 * 3 = 6
FAIL: Intentional failure — Expected 3 but got 2
This is a combination of lambdas with receivers (TestSuite.() -> Unit), infix functions (shouldBe), and higher-order functions. Test frameworks like Kotest actually use this pattern.
Kotlin’s expressiveness comes from these syntactic mechanisms. Lambdas with receivers create builder patterns, infix functions create sentence-like code, operator overloading creates intuitive operations, and typealias creates meaningful names. When these tools are combined appropriately, library users feel less like “programming in Kotlin” and more like “expressing in a domain language.”
The next part is the final installment of the series, covering practical patterns. We’ll look at delegation, annotations, reflection, and Java interoperability.


Loading comments...