skip to content

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%

answer

  1. Each block adds an implicit receiver; outer ones stay reachable
  2. Without marker: inner block can call outer builder by accident
  3. @DslMarker is a meta-annotation on your own annotation
  4. Same-marker outer receiver: no implicit access; use this@Outer
  5. Compile-time only, zero runtime cost

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.

solid answer

~40 s

In nested builders each lambda adds a new implicit receiver, but the outer receivers stay accessible. So inside td { }, an unqualified tr { } would still compile and wrongly add a row to the enclosing table — a confusing scope leak. @DslMarker is a meta-annotation: you define your own annotation annotated with @DslMarker (e.g. @DslMarker annotation class HtmlDsl) and apply it to all your builder receiver types. The compiler then forbids calling a member of an *outer* receiver implicitly when an inner receiver of the same DSL-marked group is present; you must qualify it (tr is simply unavailable, [email protected] requires explicit access). This catches structural mistakes at compile time and keeps each block scoped to its own node. It's the standard hardening step for any real nested DSL.

code

kotlin · 16 lines
kotlin
@DslMarker
annotation class HtmlDsl

@HtmlDsl class Table { val rows = mutableListOf<Tr>(); fun tr(init: Tr.() -> Unit) = Tr().apply(init).also { rows.add(it) } }
@HtmlDsl class Tr    { val cells = mutableListOf<Td>(); fun td(init: Td.() -> Unit) = Td().apply(init).also { cells.add(it) } }
@HtmlDsl class Td    { var text: String = "" }
fun table(init: Table.() -> Unit) = Table().apply(init)

table {
    tr {
        td {
            // tr { }            // COMPILE ERROR with @DslMarker — outer receiver not implicit
            this@table.tr { }     // allowed: explicit, intentional
        }
    }
}

go deeper

for a junior

May not be aware multiple receivers are in scope; can at most recall that @DslMarker 'makes DSLs safer'.

for a middle

Explains the scope-leak bug and that @DslMarker restricts implicit outer-receiver access.

for a senior

Defines a marker correctly, knows it's per-DSL, compile-time only, and that this@Outer still works.

for a principal

Designs a layered DSL's marker strategy and cites real libraries (kotlinx.html, Ktor) using it.

## The multiple-receiver problem Every `Receiver.() -> Unit` block that is currently executing contributes an **implicit receiver**. In `table { tr { td { } } }`, inside the `td` block you actually have three implicit receivers in scope: `Td` (innermost), `Tr`, and `Table`. Unqualified calls resolve to the innermost that *has* the member — but the outer ones are still reachable. That means this compiles and is almost always a bug: ```kotlin table { tr { td { tr { } // OOPS: adds a row to the *table* from inside a cell } } } ``` The `tr` resolves against the enclosing `Table` receiver. Nothing structurally wrong to the compiler — but semantically broken. ## @DslMarker `@DslMarker` is a **meta-annotation** (an annotation you put on another annotation). You define one marker per DSL and apply it to every receiver class in that DSL: ```kotlin @DslMarker annotation class HtmlDsl @HtmlDsl class Table { fun tr(init: Tr.() -> Unit): Tr = ... } @HtmlDsl class Tr { fun td(init: Td.() -> Unit): Td = ... } @HtmlDsl class Td { var text: String = "" } ``` ## The rule it enforces When two implicit receivers are both annotated with the **same** `@DslMarker`-marked annotation, members of the **outer** one are **not** available implicitly inside the inner block. So in the broken example above, `tr` is simply not callable inside `td { }` — it's a compile error. You can still reach the outer receiver explicitly with a **qualified this**: `[email protected] { }`, which makes the intent loud and deliberate. ## Key facts - The restriction applies **only between receivers sharing the same marker annotation**; receivers from a different DSL are unaffected. - It affects **implicit** access only — qualified `this@Outer` always works. - Apply the marker to the **receiver/builder types**, not to the builder functions. - It's purely a compile-time check; zero runtime cost. ## Why it matters in practice Libraries like kotlinx.html and Ktor's routing/HTML DSLs use `@DslMarker` precisely so that `route { get { } }` and similar can't accidentally pierce scopes. For any non-trivial nested DSL you ship, adding a marker is the difference between a DSL that gently guides users and one that lets them write quietly-wrong trees.

  • Does @DslMarker prevent access to the outer receiver entirely?
    No — only implicit access is blocked between same-marker receivers; you can still reach it via a qualified this@Outer.
  • Where do you put the annotation — on the builder functions or the receiver classes?
    On the receiver/builder classes (the types used as lambda receivers); the compiler keys the rule off the receiver types' annotations.
  • What happens if two builders use different @DslMarker annotations?
    The restriction doesn't apply between them — only receivers sharing the same marker annotation are mutually scoped.

Like nested folders where, without a guard, you could accidentally drop a file two levels up; @DslMarker locks you to the current folder unless you spell out the path.

saying these in an interview costs you the question

  • Thinking @DslMarker is built into a single library type rather than a meta-annotation you define
  • Claiming it blocks even qualified this@Outer access
  • Putting the marker on functions instead of receiver classes
  • Saying it has runtime cost
  • Believing it restricts across unrelated DSLs that use different markers

context