DSL Building

Kotlin Intermediate — DSL Building
Kotlin Intermediate
Course 2 · Chapter 5 · DSL Building

🏗️ DSL Building

A DSL (Domain-Specific Language) is a mini-language for one specific job, built out of ordinary Kotlin — no separate parser, no new syntax to learn, just Kotlin code shaped to read like a description of what you want rather than a sequence of instructions. Gradle's Kotlin build scripts and Jetpack Compose's UI code are both real-world Kotlin DSLs. This chapter builds a small one from scratch, using the single feature that makes it possible: function literals with receiver.

🎯 The Feature Behind Every Kotlin DSL: Lambda with Receiver

A normal lambda parameter type looks like (Int) -> String. A lambda with receiver looks like Int.() -> String — inside the lambda body, this refers to that receiver, exactly as if the lambda's code were written as a method on that type:

// A regular lambda parameter — takes a StringBuilder as an argument val normal: (StringBuilder) -> Unit = { sb -> sb.append("Hi") } // A lambda WITH RECEIVER — StringBuilder becomes "this" inside the lambda val withReceiver: StringBuilder.() -> Unit = { append("Hi") } // no "sb." prefix needed val sb = StringBuilder() sb.withReceiver() // called AS IF it were a method on sb

This is exactly how Kotlin's own apply, with, and run scope functions work — obj.apply { doThing() } reads naturally because the lambda passed to apply has obj as its receiver, so every member of obj is callable directly inside, without repeating obj. each time. DSL building is this same trick applied deliberately, on purpose-built types.

🧱 Building a Tiny HTML DSL, Step by Step

The goal: end up able to write something like this, and have it actually build a real HTML string:

val page = html { head { title("My Page") } body { h1("Welcome") p("Hello, world!") } }

Step 1 — a base Tag class that knows how to hold children and render itself:

abstract class Tag(private val name: String) { private val children = mutableListOf<Tag>() private var text: String = "" fun <T : Tag> initTag(tag: T, init: T.() -> Unit): T { tag.init() // runs the block WITH tag as the receiver children.add(tag) return tag } protected fun setText(value: String) { text = value } fun render(indent: String = ""): String { val body = if (text.isNotEmpty()) text else children.joinToString("") { "\n$it" } return "$indent<$name>$body</$name>" } override fun toString() = render() }

Step 2 — specific tag classes and builder functions:

class Html : Tag("html") { fun head(init: Head.() -> Unit) = initTag(Head(), init) fun body(init: Body.() -> Unit) = initTag(Body(), init) } class Head : Tag("head") { fun title(text: String) = initTag(Tag("title") {}) { setText(text) } } class Body : Tag("body") { fun h1(text: String) = initTag(Tag("h1") {}) { setText(text) } fun p(text: String) = initTag(Tag("p") {}) { setText(text) } } fun html(init: Html.() -> Unit): Html { val h = Html() h.init() return h }

Every builder function (head { }, body { }, and html { } itself) takes a lambda with the relevant Tag subtype as its receiver. That's the entire mechanism: inside body { h1(...); p(...) }, h1 and p are directly callable because the lambda's implicit receiver is a Body, and h1/p are members of Body. No dot-prefixing, no explicit parameter name — it just reads like markup.

🔒 @DslMarker — Preventing Scope Leakage

Without any safeguard, an inner builder block can accidentally call a method from an outer receiver, since Kotlin allows reaching an outer implicit receiver when the inner one doesn't have a matching member:

html { body { head { title("Oops") } // without @DslMarker, this could accidentally compile — // "head" isn't a Body member, but Kotlin might // still resolve it against the outer Html receiver } }

@DslMarker is an annotation you define once, then apply to the DSL's base type — it tells the compiler to only allow the nearest matching implicit receiver, blocking accidental calls to an outer one:

@DslMarker annotation class HtmlDsl @HtmlDsl abstract class Tag(private val name: String) { /* ... */ }

With @HtmlDsl applied to Tag (and therefore inherited by Html, Head, Body, and everything else in this DSL), the compiler now enforces that inside body { }, only Body's own members are reachable — the invalid head { } call above becomes a genuine compile error instead of a silent trap.

Building an Object: Java Builder Pattern vs Kotlin DSL

Java Builder PatternKotlin DSL
new PageBuilder()
  .title("My Page")
  .addHeading("Welcome")
  .addParagraph("Hello!")
  .build();
html {
  head { title("My Page") }
  body {
    h1("Welcome")
    p("Hello!")
  }
}

Both approaches build up a complex object step by step, but the DSL version expresses structure and nesting directly in the code's own shape — the indentation of head { } and body { } mirrors the actual document tree, something a flat chain of .method() calls can't represent at all.

💻 Coding Challenges

Challenge 1: A Lambda-with-Receiver Warm-Up

Write a function fun buildGreeting(init: StringBuilder.() -> Unit): String that creates a StringBuilder, calls init on it as a receiver, and returns .toString(). Use it to build a greeting by calling append(...) three times inside the lambda with no receiver prefix.

Goal: Get comfortable with the T.() -> Unit syntax before building a full DSL.

→ Solution

Challenge 2: A Simple Menu DSL

Build a small DSL for describing a restaurant menu: a Menu class holding a list of item strings with a function item(name: String, price: Double) that formats and stores "$name — $price", and a top-level fun menu(init: Menu.() -> Unit): Menu. Use it to build a 3-item menu and print each stored item.

Goal: Build a minimal DSL from scratch, following the html{} pattern from the chapter.

→ Solution

Challenge 3: Extending the HTML DSL

Using the chapter's Tag/Html/Body DSL as a starting point, add a new tag type ul (unordered list) to Body, which accepts a lambda that can call li(text: String) one or more times for list items. Build a small page using body { ul { li("First") ; li("Second") } } and print the rendered result.

Goal: Extend an existing DSL with a new nested builder, following the established pattern.

→ Solution

💡 You've Already Used This — Now You Know How It Works

Every time apply { }, with(x) { }, or a Gradle dependencies { } block has been written or read, it's been this exact mechanism in action. Building a DSL from scratch here isn't introducing something new so much as revealing the machinery behind patterns that already felt natural.

🎯 What's Next

Next chapter: Reflection & AnnotationsKClass, the reflection API, custom annotations, and use-site targets.