skip to content

@DslMarker Scope Control

@DslMarker annotates your DSL's annotation so that inside a nested block the outer receiver is hidden, turning an accidental cross-scope call into a compile error. It is the standard answer to 'how do you keep a builder DSL from letting users do the wrong thing'.

part ofKotlinoverview, primer and where to startread it →
on this pageshow

questions

5

What problem does the @DslMarker annotation solve in a Kotlin type-safe builder DSL?

level: juniorimportance: must knowfreq 55%

answer

  1. Meta-annotation on your own annotation
  2. Hides OUTER implicit receiver in nested block
  3. Same marker = only nearest receiver visible
  4. Escape with [email protected]()
  5. kotlinx.html uses @HtmlTagMarker

basics

~10 s

In a nested builder block, methods from the outer block are still callable by accident. @DslMarker hides the outer receiver inside inner blocks, so you only see the members that belong where you are.

solid answer

~40 s

Type-safe builder DSLs use lambdas with receivers (A.()->Unit), so inside a block 'this' is the builder. When you nest blocks, both the inner and outer receivers are implicitly in scope, so an outer method like html { body { title("x") } } could accidentally be called inside body even though it belongs to html. @DslMarker is a meta-annotation: you create your own annotation (e.g. @HtmlDsl) annotated with @DslMarker and put it on the receiver types/classes. Then the compiler enforces that within a nested block only the nearest receiver of that marker is accessible implicitly; outer same-marker receivers are hidden. Calling the outer member then becomes a compile error unless you qualify it explicitly (e.g. [email protected](...)). It makes the DSL safer and the autocomplete cleaner.

code

kotlin · 16 lines
kotlin
@DslMarker
annotation class HtmlDsl

@HtmlDsl
class HtmlBuilder { fun head(block: HeadBuilder.() -> Unit) {} ; fun body(block: BodyBuilder.() -> Unit) {} }
@HtmlDsl class HeadBuilder
@HtmlDsl class BodyBuilder

fun html(block: HtmlBuilder.() -> Unit) = HtmlBuilder().apply(block)

fun page() = html {
    body {
        // head { }          // ERROR: outer HtmlBuilder receiver is hidden
        this@html.head { }   // OK: explicitly qualified
    }
}

go deeper

for a junior

Can state that it prevents accidentally calling outer-block methods inside a nested builder block.

for a middle

Explains the implicit-receiver stacking problem and that you create a custom annotation marked with @DslMarker and apply it to receiver classes.

for a senior

Knows the exact rule (same marker, only nearest receiver visible), the this@label escape hatch, and that it can annotate the function type too.

for a principal

Can reason about DSL API design trade-offs, when multiple distinct markers are appropriate, and how this affects discoverability and misuse-resistance of a published DSL.

## The setup: lambdas with receivers Kotlin DSLs are built from **function types with a receiver**, written `A.() -> Unit`. Inside such a lambda, `this` refers to an instance of `A` (the *implicit receiver*), so you can call `A`'s members without qualification. A builder is just nested lambdas-with-receivers: ```kotlin html { // this: HtmlBuilder head { /* this: HeadBuilder */ } body { // this: BodyBuilder // ... } } ``` ## The problem: implicit receivers stack Inside the `body { }` block, **two** implicit receivers are in scope: `BodyBuilder` (nearest) and `HtmlBuilder` (the enclosing one). Both are available unqualified. So if `HtmlBuilder` has a member like `head { }` or `meta()`, you can accidentally call it *inside* `body`, where it makes no semantic sense: ```kotlin body { head { } // compiles! but logically wrong — head belongs to html } ``` This is a silent correctness bug and pollutes autocomplete. ## The fix: @DslMarker `@DslMarker` is a **meta-annotation** (an annotation you put on *your own* annotation). You define a marker once: ```kotlin @DslMarker annotation class HtmlDsl ``` Then you apply that marker to every receiver type in the DSL (`@HtmlDsl class HtmlBuilder`, `@HtmlDsl class BodyBuilder`, …). The rule the compiler then enforces: > Within the scope of two implicit receivers that carry the **same** `@DslMarker` annotation, only the **nearest** one is accessible without an explicit qualifier. The outer one is hidden. So `head { }` inside `body { }` now becomes a **compile error**, because `HtmlBuilder` (outer, same marker) is shadowed by `BodyBuilder`. ## Escaping the restriction The outer receiver is hidden, not gone. You can still reach it with a **qualified `this`** using a label: ```kotlin html { // this@html: HtmlBuilder body { [email protected] { } // explicit — compiles } } ``` ## Key facts - `@DslMarker` itself has `@Target(ANNOTATION_CLASS)` and `@Retention(BINARY)`. - The restriction only applies between receivers carrying the **same** marker annotation. Different markers don't shadow each other. - It can be applied to the class, or to the **function type** of the parameter (e.g. `@HtmlDsl HtmlBuilder.() -> Unit`). - The canonical real example is `@DslMarker annotation class HtmlTagMarker` in `kotlinx.html`. ## Why it matters Without it, deeply nested builders leak every ancestor's API into every block. With it, each block exposes exactly the members that belong there — safer DSLs and cleaner IDE suggestions.

  • Does @DslMarker restrict receivers that carry different marker annotations?
    No. The shadowing rule only applies between two implicit receivers annotated with the same @DslMarker annotation. Receivers with different markers (or none) are unaffected.
  • Is the outer member call permanently forbidden inside the nested block?
    No, only the implicit (unqualified) call is forbidden. You can still call it explicitly via a qualified this, e.g. [email protected] { }.

Like nested rooms where the inner room's walls block you from grabbing tools off the outer room's shelf unless you deliberately reach back through the door.

saying these in an interview costs you the question

  • Saying @DslMarker is applied directly to functions or properties rather than being a meta-annotation on an annotation class
  • Claiming it works across different marker annotations
  • Thinking it removes the outer receiver entirely (it's just hidden from implicit resolution)
  • Confusing it with @JvmStatic or @JvmName
  • Believing you must write @DslMarker on every call site

context

open as a page

Show how to define and apply a @DslMarker annotation. Where exactly must the marker be placed for the scope restriction to take effect?

level: middleimportance: must knowfreq 45%

basics

~10 s

Create an annotation class and mark it with @DslMarker. Then put that annotation on the builder/receiver classes (or on the lambda's receiver type). The restriction kicks in when two implicit receivers share that marker.

open as a page

After @DslMarker hides an outer receiver inside a nested block, how can you still legitimately call a member of that outer receiver?

level: middleimportance: should knowfreq 30%

basics

~10 s

Use a labeled this, like [email protected](). The label names the enclosing block, giving you explicit access to the receiver that the marker hid from implicit calls.

open as a page

How does the scope restriction differ when two nested receivers share the same @DslMarker annotation versus carrying different marker annotations? When would you deliberately use distinct markers?

level: seniorimportance: should knowfreq 25%

basics

~20 s

Shadowing only happens between receivers with the SAME marker. Different markers don't hide each other, so the outer receiver stays implicitly callable. Use distinct markers when you intentionally want an inner DSL to reach an outer, unrelated one.

open as a page

Explain how the @DslMarker check is enforced (compile-time vs runtime), what the annotation's own retention/target are, and one limitation candidates often overlook.

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

It's a compile-time check by the Kotlin compiler on implicit receiver resolution — no runtime cost. The marker annotation itself targets annotation classes and has binary retention. A common gap: it only governs implicit receiver calls, not extension functions you import.

open as a page