skip to content

Receiver Lambdas

The mechanic underneath every Kotlin DSL: receiver lambdas, how nested ones stack implicit receivers, and how you stop the outer scope leaking into the inner one. Get this and DSL design stops being magic.

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

explore

questions

20

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

In a type-safe builder like an HTML DSL, what is an "implicit receiver" and why can you call functions like body { ... } inside html { ... } without writing any object name before them?

level: juniorimportance: must knowfreq 60%

basics

~20 s

A lambda with a receiver runs as if its body were inside a special object. That object is the 'implicit receiver', so its members can be called by name alone, with no dot before them.

open as a page

What is a function type with receiver like A.() -> Unit, and how does it differ from a plain (A) -> Unit?

level: juniorimportance: must knowfreq 70%

basics

~10 s

It is a lambda that runs as if it lives inside an object of type A. Inside the block, you can call A's methods directly without naming the object, because A becomes 'this'.

open as a page

How do you make two receivers available inside the same block using only standard scope functions, e.g. with(a) { b.apply { ... } }, and which receiver wins when both have a member with the same name?

level: juniorimportance: must knowfreq 55%

basics

~20 s

Nest scope functions: with(a) { b.apply { ... } }. Inside the inner block both a and b are reachable. If both define the same name, the innermost receiver (b) is used unless you qualify it.

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

When receiver lambdas are nested, several implicit receivers are in scope at once. How does Kotlin decide which receiver an unqualified call resolves against?

level: middleimportance: must knowfreq 55%

basics

~10 s

The closest (innermost) receiver wins. Kotlin looks at the nearest enclosing receiver first; if it has a matching member, that's used. Only if it doesn't does it look further out.

open as a page

Show how fun html(block: HTML.() -> Unit) lets callers write html { body { ... } } with unqualified member calls. Why does the unqualified call work?

level: middleimportance: must knowfreq 65%

basics

~20 s

The function gives the lambda an HTML object as its hidden 'this'. So inside the braces you can call HTML's methods like body() directly, with no object name in front. That is how the builder reads like English.

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

What does this@Html mean inside a nested builder, and when do you need qualified this (this@Label) instead of a bare this or an unqualified call?

level: middleimportance: should knowfreq 45%

basics

~10 s

this@Html means 'the Html receiver specifically', not whichever receiver is innermost. You use it when an outer receiver is shadowed by an inner one and you need to reach the outer object explicitly.

open as a page

Implement apply and with yourself using receiver lambdas. What are their exact signatures, and how do they differ from let?

level: middleimportance: should knowfreq 60%

basics

~20 s

apply runs a block on an object and returns the object; with does the same but returns the block's result. Both make the object 'this'. let differs: it passes the object as 'it' and returns the result.

open as a page

What problem do Kotlin's context parameters (context(...)) solve, how do they differ from an extension receiver, and how do you declare and call a function that requires a context?

level: middleimportance: should knowfreq 35%

basics

~20 s

Context parameters let a function require some surrounding object (like a logger or transaction) to be implicitly available, without it being a normal parameter or the receiver. You declare context(x: T) before the function and the caller must have a matching context in scope.

open as a page

You need a logger, a transaction, and the entity available inside one function body. Compare nesting scope functions vs. using context parameters, and explain how to combine an extension receiver with context parameters.

level: middleimportance: should knowfreq 20%

basics

~20 s

Nesting scope functions (with/apply) works today with no opt-in but gets verbose and clash-prone as you stack receivers. Context parameters declare the ambient dependencies once and reference them by name, but are experimental. You can keep the entity as the extension receiver and add the logger/transaction as context parameters.

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

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.

level: seniorimportance: should knowfreq 30%

basics

~20 s

The 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.

open as a page

Given multiple implicit receivers plus extension functions in scope, walk through Kotlin's priority order for resolving an unqualified call. Where do member functions of inner vs outer receivers and imported extensions sit?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Kotlin tries the closest receiver first, considering both its members and extensions on it, before moving to outer receivers. A nearer receiver — member or applicable extension — beats anything reachable only through a farther receiver.

open as a page

Given fun build(block: StringBuilder.() -> Unit), what kinds of values can you pass as block? Can a regular extension function or a (StringBuilder) -> Unit lambda be passed?

level: seniorimportance: should knowfreq 40%

basics

~20 s

You can pass a lambda written with the receiver style, or a reference to an extension function on StringBuilder. A plain (StringBuilder) -> Unit lambda is a different type, so it needs adapting, though references convert in many cases.

open as a page

Inside a receiver lambda, what does this refer to, and how do you reach an outer receiver or the lambda's own label? Explain this@Outer and the difference from named-parameter access.

level: seniorimportance: should knowfreq 35%

basics

~20 s

Inside the block, this is the receiver the lambda was invoked on. When nested inside another receiver block, you reach the outer one with a label, like this@Outer. A plain parameter is reached by its name or it instead.

open as a page

When multiple implicit receivers/contexts are in scope and several could resolve the same unqualified call, how does Kotlin decide, and how do you disambiguate?

level: seniorimportance: should knowfreq 25%

basics

~20 s

Kotlin searches receivers from the innermost scope outward and uses the closest one that has a matching member; a nearer receiver shadows farther ones. If a single scope offers two equally valid candidates it's an ambiguity error. You disambiguate with labeled this (this@outer) or by qualifying the call.

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

Kotlin had an experimental 'context receivers' prototype that was redesigned into 'context parameters'. What changed, why, and what does that mean for code and library design today?

level: principalimportance: nice to knowfreq 12%

basics

~20 s

Old context receivers (context(Logger)) made the context an unnamed implicit this, which caused clashes and confusion. They were redesigned into context parameters (context(logger: Logger)) where each context is a named value. The named version is the supported direction; the old prototype is being removed.

open as a page