skip to content

Design a minimal type-safe builder DSL for an HTML-like structure (`html { head { title { +"x" } } body { p { +"y" } } }`). Walk through how receiver lambdas, nesting, and node collection work.

level: seniorimportance: should knowfreq 40%

answer

  1. Base Tag with children + initTag helper
  2. initTag: build child, child.init(), add, return
  3. Typed methods per tag enforce grammar
  4. @DslMarker stops outer-receiver leaks
  5. html { } bootstraps via apply

basics

~20 s

Make a class per tag holding children. Each tag has methods that take a receiver lambda, create a child, run the lambda on it, add it to the children, and return it. A top-level function starts the tree. Text uses a unaryPlus operator.

solid answer

~40 s

Model each element as a class (a `Tag` base with a `children: MutableList<Tag>` and a `render`). Give the base a generic helper that takes a `T.() -> Unit`: it constructs the child `T`, applies the receiver lambda (`child.init()`), adds it to `children`, and returns it. Concrete tags (`HTML`, `BODY`, `P`) expose named functions delegating to that helper: `fun body(init: BODY.() -> Unit) = initTag(BODY(), init)`. A free function `html { ... }` bootstraps the root and applies its lambda. Text content is added via `operator fun String.unaryPlus()` on tags. Each `{ }` opens a new receiver scope, so nesting is automatic. Add a `@DslMarker` annotation (e.g. `@HtmlTagMarker`) on the tag types so an inner `p { }` can't accidentally call an outer `body { }`. This is exactly how kotlinx.html is structured.

code

kotlin · 13 lines
kotlin
@DslMarker annotation class TagMarker

@TagMarker abstract class Tag(val name: String) {
    val children = mutableListOf<Tag>()
    protected fun <T : Tag> init(c: T, b: T.() -> Unit): T { c.b(); children += c; return c }
    operator fun String.unaryPlus() { children += Text(this) }
}
class Text(val v: String) : Tag("#text")
class HTML : Tag("html") { fun body(b: BODY.() -> Unit) = init(BODY(), b) }
class BODY : Tag("body") { fun p(b: P.() -> Unit) = init(P(), b) }
class P : Tag("p")

fun html(b: HTML.() -> Unit) = HTML().apply(b)

go deeper

for a junior

Can read the DSL and understand +"text" but would struggle to design the builder helper.

for a middle

Implements the per-tag builder methods and the unaryPlus, gets nesting working.

for a senior

Designs the generic initTag, enforces grammar via typed methods, and adds @DslMarker for scope safety.

for a principal

Considers rendering strategy, immutability, performance, and how the design scales to a real markup library like kotlinx.html.

## Goal Build a small DSL that produces a tree from declarative code, type-safely (you can only put `<p>` where the API allows it). ## Step 1 — a base class that collects children ```kotlin @DslMarker annotation class HtmlTagMarker @HtmlTagMarker abstract class Tag(val name: String) { val children = mutableListOf<Tag>() val text = StringBuilder() // the reusable builder helper protected fun <T : Tag> initTag(child: T, init: T.() -> Unit): T { child.init() // run the receiver lambda with child as this children += child return child } operator fun String.unaryPlus() { text.append(this) } fun render(sb: StringBuilder) { sb.append("<").append(name).append(">").append(text) children.forEach { it.render(sb) } sb.append("</").append(name).append(">") } } ``` The heart is `initTag`: it (1) takes the already-built child and a receiver lambda, (2) invokes that lambda on the child so nested calls configure *it*, (3) records the child, (4) returns it. ## Step 2 — concrete tags expose typed entry points ```kotlin 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(init: TITLE.() -> Unit) = initTag(TITLE(), init) } class TITLE : Tag("title") class BODY : Tag("body") { fun p(init: P.() -> Unit) = initTag(P(), init) } class P : Tag("p") ``` Because `body` is only declared on `HTML`, you *cannot* write `head { body { } }` — the type system enforces the grammar. That's the 'type-safe' in type-safe builder. ## Step 3 — bootstrap function ```kotlin fun html(init: HTML.() -> Unit): HTML = HTML().apply(init) ``` ## Step 4 — use it ```kotlin val doc = html { head { title { +"Hello" } } body { p { +"World" } } } val sb = StringBuilder(); doc.render(sb) // <html><head><title>Hello</title></head><body><p>World</p></body></html> ``` ## Why each piece matters - **Receiver lambda `T.() -> Unit`** — gives each block its own implicit `this`, so calls are unqualified. - **Nesting** — falls out naturally because `initTag` runs the child's lambda before returning; deeper blocks just call deeper builders. - **`unaryPlus`** — turns `+"text"` into content. - **`@DslMarker` (`@HtmlTagMarker`)** — without it, inside `title { }` you could still call `body { }` (an outer-receiver member) by implicit resolution; the marker forbids that, requiring explicit `[email protected] { }` if you really mean it. - **Return value `T`** — lets you keep a handle or chain. ## Performance / correctness notes - The lambdas are typically `inline`d by the standard helpers (`apply`), so there's little overhead. - Building eagerly into mutable nodes is simple; for large trees you might stream rendering instead.

  • How does the type system stop you from putting a `<p>` directly under `<head>`?
    The `p(...)` builder method is declared only on `BODY` (and wherever it's valid), not on `HEAD`. Since the receiver inside `head { }` is `HEAD`, `p { }` simply doesn't resolve — a compile error.
  • Why add `@DslMarker` to the Tag base?
    It marks all tag receivers as the same DSL scope so that, when receivers nest, an inner block cannot *implicitly* call a member of an outer receiver; you'd have to qualify with `this@html`. This catches accidental cross-scope calls at compile time.

saying these in an interview costs you the question

  • Storing children but never invoking the child's lambda
  • Putting all tag methods on one type, losing type safety
  • Forgetting @DslMarker and allowing outer-receiver leakage
  • Returning Unit so the builder can't be chained or captured
  • Re-creating the child after configuring it (losing the config)

context