Skip to content
ioob.dev
Go back

Kotlin Part 11 — DSL and Advanced Functions

· 3 min read
Kotlin Series (11/12)
  1. Kotlin Beginner Part 1 — Variables and Types
  2. Kotlin Beginner Part 2 — Conditionals and Loops
  3. Kotlin Beginner Part 3 — Functions
  4. Kotlin Beginner Part 4 — Classes and Objects
  5. Kotlin Beginner Part 5 — Collections and Lambdas
  6. Kotlin Part 6 — Null Safety Advanced
  7. Kotlin Part 7 — Generics
  8. Kotlin Part 8 — sealed class and enum
  9. Kotlin Part 9 — Coroutines Basics
  10. Kotlin Part 10 — Coroutines Advanced
  11. Kotlin Part 11 — DSL and Advanced Functions
  12. Kotlin Part 12 — Practical Patterns
Table of contents

Table of contents

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.

OperatorFunction Name
a + ba.plus(b)
a - ba.minus(b)
a * ba.times(b)
a[i]a.get(i)
a[i] = va.set(i, v)
a()a.invoke()
a in bb.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.

-> Part 12: Practical Patterns


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Kotlin Part 10 — Coroutines Advanced
Next Post
Kotlin Part 12 — Practical Patterns