skip to content

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%

answer

  1. Receiver lambda: builder fn per child, appends as side effect, configurable inline
  2. Varargs: build children first, pass positionally, spread with *
  3. unaryPlus (+"x") is the kotlinx.html-style hybrid for leaf children
  4. Lambdas allow loops/ifs inside the block for data-driven trees
  5. Mixing both is idiomatic

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.

solid answer

~40 s

The receiver-lambda style (the canonical nested builder) makes each child via a builder function inside the parent's block — tr { td { } td { } } — appending to the parent's list as a side effect, which reads as a tree and supports per-child configuration. The vararg/operator style constructs children eagerly and passes them positionally — Tr(Td("a"), Td("b")) or using unaryPlus (+) inside a block (children += in kotlinx.html style) — which is cleaner for flat lists of already-built nodes. Receiver lambdas shine when children themselves are configured/nested; varargs/+ shine for terse leaf lists and when children are produced elsewhere (e.g., mapped from data). Many real DSLs combine both: a receiver-lambda outer with a unaryPlus operator (operator fun String.unaryPlus()) for adding text children inline.

code

kotlin · 7 lines
kotlin
// Receiver-lambda with a unaryPlus hybrid + data-driven loop
@DslMarker annotation class Html
@Html class Ul { val items = mutableListOf<String>(); operator fun String.unaryPlus() { items += this } }
fun ul(init: Ul.() -> Unit) = Ul().apply(init)

val names = listOf("a", "b", "c")
val list = ul { for (n in names) +n }   // loop inside the receiver block

go deeper

for a junior

Knows the receiver-lambda block style and can pass simple children, but may not know varargs/unaryPlus alternatives.

for a middle

Compares the styles, knows unaryPlus and the spread operator, and picks based on child complexity.

for a senior

Designs a hybrid DSL and reasons about data-driven generation with control flow inside blocks.

for a principal

Weighs API ergonomics, discoverability, and consistency across a large DSL surface.

## Two idioms for 'add a child' ### 1. Receiver-lambda builder (the nested-builder default) Each child gets a builder function on the parent; calling it appends to the parent's list: ```kotlin tr { td { text = "a" } td { text = "b" } } ``` Pros: reads as a tree, each child can be configured/nested inline, type-safe per legal child. Cons: a bit ceremonial for trivial children; appending is a side effect. ### 2. Varargs / constructor Build children first, pass them positionally: ```kotlin fun tr(vararg cells: Td): Tr = Tr(cells.toList()) val r = tr(Td("a"), Td("b")) ``` Pros: explicit, functional, easy to splat a pre-built list with the spread operator `*list.toTypedArray()`. Cons: no inline receiver configuration; nesting deeper gets noisy. ### 3. unaryPlus operator (a common hybrid) DSLs like kotlinx.html add children with the unary `+` operator inside a receiver block, especially for text: ```kotlin @DslMarker annotation class Html @Html class P { val parts = mutableListOf<String>(); operator fun String.unaryPlus() { parts += this } } fun p(init: P.() -> Unit) = P().apply(init) p { +"Hello "; +"world" } // each +"..." appends a child ``` `operator fun String.unaryPlus()` is defined as a member-extension on the builder, so `+"x"` inside the block appends `"x"` as a child. This keeps text children terse while still using the receiver-lambda outer. ## Choosing - **Children configured/nested inline** → receiver-lambda builder. - **Flat list of simple/leaf children, or children produced elsewhere (mapped from data)** → varargs or `+`/`children +=`. - **Mixing** is normal and idiomatic: receiver-lambda for structural nodes, `unaryPlus` for text/leaf nodes. ## Data-driven children Receiver lambdas compose with normal Kotlin control flow because the block is just code: ```kotlin table { for (row in data) tr { for (c in row) td { text = c } } } ``` That loop-inside-the-block ability is a big reason receiver-lambda builders win for dynamic trees — you can't loop inside a vararg argument list as naturally.

  • How does unaryPlus work as a child-adder in a DSL?
    You define operator fun String.unaryPlus() as a member-extension on the builder; inside the receiver block, +"x" calls it and appends "x" to the parent's children.
  • Why are receiver-lambda builders better for data-driven trees?
    The block is ordinary code, so you can use for/if/when inside it to emit children conditionally — you can't do that inside a fixed vararg argument list.

saying these in an interview costs you the question

  • Claiming varargs allow per-child inline configuration like nested blocks do
  • Not knowing unaryPlus is a defined operator function, thinking + is magic
  • Forgetting the spread operator * is needed to pass a list as varargs
  • Saying you cannot use loops inside a receiver-lambda block
  • Treating the two styles as mutually exclusive rather than combinable

context