A teammate's HTML-style DSL compiles, but a tag added inside an inner block mysteriously attaches to the outer element. Diagnose how implicit receiver resolution causes this, and give two concrete fixes.
answer
- Missing inner member -> falls through to outer receiver
- Compiles because outer member truly applies
- Diagnose: which type declares the method?
- Fix 1: add the member to the inner type
- Fix 2: @DslMarker forbids outer-receiver unqualified calls
basics
~20 sThe inner block lacks that builder method, so the call silently falls through to the outer receiver, which has it. Fix by adding the method to the inner type, or annotate the DSL with @DslMarker so such cross-level calls become compile errors.
solid answer
~50 sUnqualified calls resolve innermost-first but fall through to an outer receiver when the inner one has no matching member. If the inner builder type is missing a tag method that the outer type defines, the call binds to the outer receiver, so the element is built on the wrong (outer) object — and it still compiles, hiding the bug. Diagnose by checking which receiver actually owns the method (is it on the inner type or only the outer?). Fix 1: give the inner type its own method so resolution stays inner. Fix 2: annotate the receiver types with a @DslMarker annotation; the compiler then forbids the unqualified outer-receiver call, surfacing it as an error and forcing either the inner method or an explicit this@outer. The marker turns silent fall-through shadowing into a loud compile-time failure — the standard guard for builder DSLs.
code
kotlin · 16 lines@DslMarker annotation class TableDsl
@TableDsl class Table {
fun row(b: Row.() -> Unit) = Row().b()
fun cell(t: String) = println("cell on TABLE: $t")
}
@TableDsl class Row // intentionally lacks cell()
fun table(b: Table.() -> Unit) = Table().b()
table {
row {
// cell("oops") // ERROR with @DslMarker (was: silent outer call)
this@table.cell("ok") // explicit if outer was truly intended
}
}go deeper
Might just see 'wrong output' without identifying the receiver fall-through.
Can explain inner-lacks-member fall-through to the outer receiver.
Diagnoses via receiver ownership and prescribes both the inner-member fix and @DslMarker, plus this@Label for intentional cases.
Mandates @DslMarker as a DSL-design default and reasons about marker scoping across a large builder hierarchy to make whole classes of bugs impossible.
## The symptom A nested builder where content written in an inner block ends up on the outer element. It compiles cleanly, runs, and produces wrong structure — the worst kind of DSL bug. ## Root cause: fall-through to an outer receiver Unqualified resolution searches the implicit receivers **innermost → outermost** and binds to the **first receiver that has the member**. If the inner receiver type does **not** declare the called builder method but the outer one does, the call legitimately resolves to the **outer** receiver. The lambda is the inner block, but the method runs against the outer object — so the new tag is appended to the outer element. ```kotlin class Table { fun row(b: Row.() -> Unit) { /* ... */ } ; fun cell(t: String) { /* on Table! */ } } class Row { /* note: no cell() here */ } fun table(b: Table.() -> Unit) = Table().apply(b) table { row { cell("oops") // Row has no cell(); falls through to Table.cell -> wrong element } } ``` Because `Row` lacks `cell`, `cell("oops")` resolves against `Table`, attaching the cell to the table, not the row. ## How to diagnose 1. Identify the receiver types at each nesting level (the lambda receiver types). 2. Check **which type actually declares the called method**. If it's only on the outer type, you've found the fall-through. 3. Optionally make it explicit: temporarily write `this.cell(...)` or `[email protected](...)` — if `[email protected]` fails to compile, that confirms `Row` doesn't have it. ## Fix 1 — give the inner type the member Add `cell` to `Row` so the inner receiver matches and resolution stays inner: ```kotlin class Row { fun cell(t: String) { /* on Row */ } } ``` ## Fix 2 — @DslMarker to forbid silent outer calls Mark the receiver types with a `@DslMarker` annotation. The compiler then **rejects** an unqualified call that would bind to an *outer* receiver of the same marker, converting the silent fall-through into a compile error: ```kotlin @DslMarker annotation class HtmlTagMarker @HtmlTagMarker class Table { fun cell(t: String) {} ; fun row(b: Row.() -> Unit) {} } @HtmlTagMarker class Row table { row { cell("x") } } // now: compile error -> 'cell' is an outer-receiver call ``` The developer must then add `cell` to `Row` (the real fix) or explicitly write `[email protected]("x")` if attaching to the table was actually intended. ## Why @DslMarker is the durable answer Adding the missing member fixes one case; `@DslMarker` prevents the *entire class* of accidental cross-level calls for every method across the DSL. Production DSLs (kotlinx.html, Gradle Kotlin DSL) apply it precisely for this reason. ## Recap of the mechanism - Inner-first search + fall-through = outer receiver can be hit silently. - It compiles because the outer member is genuinely applicable. - Guard with `@DslMarker`; intentional outer access uses `this@Label`.
- Why does the bug compile at all instead of being an unresolved reference?Because an outer receiver genuinely declares an applicable member, so resolution succeeds via fall-through. Nothing is unresolved — it just binds to the wrong (outer) receiver.
- After adding @DslMarker, how do you still call the outer member when you really mean to?Qualify it: [email protected]("..."). @DslMarker only blocks the implicit outer call; explicit qualified this is still allowed.
- Does @DslMarker affect calls within the same receiver level?No. It only restricts implicit access to outer receivers carrying the same marker; same-level (innermost) calls resolve normally.
Like calling a help line that forwards your request to head office because the local branch can't handle it — it 'works', but the wrong office acts on it.
saying these in an interview costs you the question
- Saying the code shouldn't compile / is an unresolved reference
- Blaming runtime reflection instead of static fall-through
- Only suggesting 'rename the method' without @DslMarker or the inner member
- Thinking @DslMarker blocks all nested calls, not just outer same-marker ones
- Not knowing this@Label is the sanctioned way to still reach the outer receiver