skip to content

Without @DslMarker, what bug can appear in a nested markup DSL, and how does annotating tag types with a @DslMarker annotation fix it?

level: seniorimportance: must knowfreq 50%

answer

  1. Problem: outer tag functions leak into inner blocks
  2. @DslMarker is a meta-annotation on your own annotation
  3. Annotate the common tag base class
  4. Same-marker receivers: only innermost implicit
  5. Outer still reachable via this@Label

basics

~20 s

Without it, an inner block can accidentally call functions from an outer tag, building a wrong tree. @DslMarker tells the compiler to hide outer receivers inside nested blocks, so only the closest tag's functions are available implicitly.

solid answer

~40 s

In nested receiver lambdas, every enclosing receiver stays an implicit receiver. So inside head { } you could call body { } (a function of the outer HTML), silently attaching body in the wrong place — a real correctness bug. @DslMarker fixes this: you create an annotation class annotated @DslMarker, then annotate your tag types (or their common base) with it. The compiler treats two implicit receivers that share the same DSL-marker annotation as conflicting; only the nearest one is accessible implicitly. The outer receiver isn't gone — you can still reach it explicitly via this@HTML — but unqualified calls to outer-tag functions now fail to compile, catching the structural mistake. kotlinx.html applies its @HtmlTagMarker to all tags for exactly this reason.

code

kotlin · 22 lines
kotlin
@DslMarker
annotation class HtmlTagMarker

@HtmlTagMarker
abstract class Tag(val name: String) {
    val children = mutableListOf<Any>()
}
class HTML : Tag("html") {
    fun head(b: HEAD.() -> Unit) { children += HEAD().apply(b) }
    fun body(b: BODY.() -> Unit) { children += BODY().apply(b) }
}
class HEAD : Tag("head")
class BODY : Tag("body")
fun html(b: HTML.() -> Unit) = HTML().apply(b)

val ok = html {
    head { }
    body {
        // head { }            // compile error thanks to @DslMarker
        this@html.head { }      // allowed: explicit outer receiver
    }
}

go deeper

for a junior

Can state that @DslMarker stops inner blocks from accidentally using outer tag functions.

for a middle

Knows you define a meta-annotated annotation, put it on tag types, and that only the nearest same-marked receiver stays implicit.

for a senior

Explains the leakage bug concretely, the same-marker conflict rule, and that this@Label is the explicit escape hatch.

for a principal

Frames it as turning structural discipline into compile-time guarantees, discusses marker placement on a base class and cross-DSL interactions of distinct markers.

## The leakage problem Receiver lambdas stack their receivers. Inside deeply nested DSL blocks, **all** enclosing tags remain implicit receivers, so their builder functions are callable without a qualifier: ```kotlin html { // this: HTML head { // this: HEAD, but HTML still in scope head { } // OOPS: HTML.head() is reachable here -> nested head inside head } } ``` This compiles and produces a malformed tree. The bug is silent because the outer receiver's functions are perfectly valid candidates for unqualified resolution. ## @DslMarker to the rescue `@DslMarker` is a meta-annotation. You define your own marker and apply it to the tag types: ```kotlin @DslMarker annotation class HtmlTagMarker @HtmlTagMarker abstract class Tag(val name: String) { /* ... */ } class HTML : Tag("html") { fun head(b: HEAD.() -> Unit) { /* ... */ } } class HEAD : Tag("head") { /* ... */ } ``` Now the rule kicks in: **if two implicit receivers in scope are marked with the same DSL-marker annotation, only the innermost one is available as an implicit receiver.** The outer `HTML` receiver becomes inaccessible for *unqualified* calls inside `head { }`, so `head { }` (an `HTML` member) no longer resolves implicitly — the mistaken nesting fails to compile. ## You can still reach the outer receiver explicitly `@DslMarker` only restricts **implicit** access. When you genuinely need the parent, use a **labeled this**: ```kotlin html { body { [email protected] { } // explicit, compiles } } ``` ## Key points to remember - The annotation must itself be annotated `@DslMarker` (a meta-annotation), typically with `@Target(AnnotationTarget.CLASS)`. - Apply it once on a **common base class** so all tags inherit the marker. - It compares markers by **annotation type**: receivers marked with the *same* marker conflict; receivers with *different* markers don't. - It changes **resolution**, not the object graph — `this@Outer` still works. - kotlinx.html uses `@HtmlTagMarker`; Ktor, kotlinx.html, and Gradle Kotlin DSL all use this technique. ## Why it matters at senior level Without `@DslMarker`, a markup or config DSL is only safe by discipline. With it, the type system enforces that each block can only configure its own node, turning a class of silent structural bugs into compile errors — the whole point of a *type-safe* builder.

  • Does @DslMarker remove the outer receiver entirely?
    No. It only blocks implicit (unqualified) access. The outer receiver is still reachable with a labeled this, e.g. this@html.
  • What happens if two nested receivers carry different DSL-marker annotations?
    They don't conflict, so both remain implicitly accessible — the restriction applies only between receivers sharing the same marker annotation type.
  • Where should you place the marker annotation in a tag hierarchy?
    On a common base class, so every tag inherits it and the whole DSL is covered consistently.

It's like a privacy screen: inside the inner room you can only touch the inner room's controls; the outer room's controls are hidden unless you point at them by name.

saying these in an interview costs you the question

  • Thinking @DslMarker is applied directly to functions or lambdas
  • Believing it deletes/hides the parent object rather than restricting implicit resolution
  • Not knowing this@Label still reaches the outer receiver
  • Claiming different markers also conflict
  • Saying it's a runtime check rather than compile-time

context