skip to content

Nested Builder DSLs

Nesting builders means each builder method creates a child, runs the child's receiver lambda, and attaches it to the parent, which is how a DSL mirrors a tree. Being able to write that three-line pattern is the practical test.

part ofKotlinoverview, primer and where to startread it →
on this pageshow

questions

5

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?

level: juniorimportance: must knowfreq 55%

answer

  1. Create child → run its receiver lambda → add to parent's children
  2. Param type is Child.() -> Unit (lambda with receiver)
  3. td { } only callable where Tr is the receiver
  4. child.apply(init) returns the child
  5. Top-level entry function starts the chain

basics

~20 s

A 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 s

A 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 lines
kotlin
class 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

for a junior

Can describe the visual tree and that each block configures one node; recognizes lambda-with-receiver syntax.

for a middle

Can write the three-step builder function (create, run receiver lambda, attach) correctly and explain why nesting is type-safe.

for a senior

Discusses apply-based one-liners, top-level vs member entry, and where @DslMarker fits.

for a principal

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

context

open as a page

Walk through exactly how the receiver lambda makes td { text = "x" } resolve text against the child node, and show how to make an apply-based one-liner builder.

level: middleimportance: must knowfreq 50%

basics

~20 s

The block you pass is a lambda whose 'this' is the child node. So inside td { }, text means the child's text. You can write the builder in one line with apply, which runs the block on the child and returns it.

open as a page

Compare two ways to add children in a nested DSL: a receiver-lambda builder (tr { td {} td {} }) versus passing children as varargs/operators. When would you choose each?

level: middleimportance: should knowfreq 30%

basics

~20 s

With receiver lambdas you call a builder function for each child inside a block. With varargs you create the children first and pass them in. Use lambdas when children are configured inline; use varargs when children are simple values you already have.

open as a page

When building nested builders like table { tr { td { } } }, what problem can arise with multiple receivers in scope, and how does @DslMarker solve it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Inside a deeply nested block, the outer node is still in scope, so you can accidentally call an outer builder from the wrong place (like adding a row from inside a cell). @DslMarker blocks that by allowing only the nearest receiver implicitly.

open as a page

You're designing a public nested builder DSL that produces an immutable tree. What design choices prevent builder/mutation leaks and partially-built nodes from escaping?

level: principalimportance: nice to knowfreq 22%

basics

~10 s

Keep mutable builders hidden, build into private mutable lists, and only hand back a finished read-only tree. Don't return the builder or let the configuration block run after building finishes.

open as a page