What is a nested builder DSL in Kotlin (e.g. html { body { p { } } }), and what does an outer builder function like tr { td { } } actually do under the hood?
answer
- Create child → run its receiver lambda → add to parent's children
- Param type is Child.() -> Unit (lambda with receiver)
- td { } only callable where Tr is the receiver
- child.apply(init) returns the child
- Top-level entry function starts the chain
basics
~20 sA nested builder lets you describe a tree by writing blocks inside blocks. Each outer function creates a child object, runs the block you pass to fill it in, then stores that child in its parent.
solid answer
~40 sA nested builder DSL composes lambdas-with-receiver to mirror a tree. tr { ... } is a member (or extension) function on the parent (a Tr/Table node) that: (1) constructs a child node (Td), (2) configures it by invoking the passed lambda with the child as receiver — td.apply(init) or init(td) where init has type Td.() -> Unit, and (3) adds the child to the parent's children list, returning it. Because the lambda's receiver is the child, calls inside it (like text("x")) resolve against the child. Nesting works because each node type exposes builder functions for its legal children, so td { } is only callable where a Td is the receiver. The result is a fully built immutable tree you then render or use.
code
kotlin · 6 linesclass Table { val rows = mutableListOf<Tr>(); fun tr(init: Tr.() -> Unit) = Tr().apply(init).also { rows.add(it) } }
class Tr { val cells = mutableListOf<Td>(); fun td(init: Td.() -> Unit) = Td().apply(init).also { cells.add(it) } }
class Td { var text: String = "" }
fun table(init: Table.() -> Unit) = Table().apply(init)
val t = table { tr { td { text = "A" }; td { text = "B" } } }go deeper
Can describe the visual tree and that each block configures one node; recognizes lambda-with-receiver syntax.
Can write the three-step builder function (create, run receiver lambda, attach) correctly and explain why nesting is type-safe.
Discusses apply-based one-liners, top-level vs member entry, and where @DslMarker fits.
Frames it as an embedded tree grammar, weighs immutability/builder-leak trade-offs and library API ergonomics.
## The idea A **nested builder DSL** lets you build a tree-shaped data structure using nested code blocks that visually mirror the tree. The classic example is the HTML builder from the Kotlin docs: ```kotlin val page = html { body { p { text("Hello") } } } ``` Each of `html`, `body`, `p` is a function that builds one node and lets you fill in its children. ## The three things an outer builder does Given `tr { td { } }`, the `td` function does **exactly three things**: 1. **Create the child**: `val child = Td()`. 2. **Configure it** by running the block you passed, with the child as the **receiver**. 3. **Attach it**: add `child` to the parent's `children` list and (often) return it. ```kotlin class Tr { val children = mutableListOf<Td>() fun td(init: Td.() -> Unit): Td { // 1 val child = Td() child.init() // 2 — same as init(child) / child.apply(init) children.add(child) // 3 return child } } class Td { var text: String = "" } ``` ## Why nesting works: lambda with receiver The parameter type `Td.() -> Unit` is a **function type with receiver**. Inside the block you pass, `this` is the `Td`, so unqualified calls (e.g. another nested builder, or `text = "x"`) resolve against the `Td`. A node only exposes builder functions for the children it is *allowed* to contain, so `td { }` is callable only where a `Tr` is the receiver. That is what makes the grammar of the tree type-safe. ## Common variations - `child.init()` vs `child.apply(init)` — `apply` returns the child, so `fun td(init: Td.() -> Unit) = Td().apply(init).also { children.add(it) }` is a one-liner. - The top-level entry (`html { }`) is usually a **top-level function**, not a member, so it can start the chain. - Real-world DSLs often add `@DslMarker` to stop accidentally calling an *outer* receiver's members from an *inner* block. ## Result You end up with a fully constructed object graph (a tree). The DSL is just ordinary Kotlin functions and lambdas — no reflection, no macros.
- Why is the top-level html { } usually a top-level function rather than a member?There is no parent receiver to call it on, so it must be reachable globally to start the builder chain; nested builders are members because they need a parent to attach to.
- What is the difference between init(child) and child.apply(init)?Both run the block with child as receiver; apply additionally returns child, letting you write it as a single expression.
Like filling out a nested outline: each new bullet is created, you write its sub-bullets inside it, then it gets pinned under its parent bullet.
saying these in an interview costs you the question
- Thinking it uses reflection or annotation processing — it's plain functions and lambdas
- Forgetting the child must be added to the parent's children list
- Not knowing the lambda parameter is a function-type-with-receiver
- Claiming the block runs before the child is created
- Confusing it with the Java step-builder pattern that returns 'this' for chaining