Receiver function type memungkinkan kita menulis DSL bersarang seperti html { body { h1 { ... } } }. Namun, pola ini membawa satu jebakan halus: di dalam block body { }, receiver luar html tetap dapat diakses secara implisit, sehingga pemanggilan method pada receiver luar dapat terjadi tanpa kesadaran pengguna. Pada chapter ini kita akan memahami masalah implicit receiver, mempelajari anotasi @DslMarker yang menjadi solusi Kotlin, dan melihat perbandingan kode sebelum dan sesudah penerapannya. Pengetahuan ini penting agar DSL yang kita rancang menolak struktur tidak valid sejak kompilasi, bukan membiarkannya lolos ke runtime.

Masalah Implicit Receiver #

Saat kita menulis DSL bersarang dengan receiver lambda, setiap block membentuk scope receiver baru. Yang menarik, receiver luar tidak hilang — semua receiver aktif tetap tersedia sebagai implicit receiver di dalam block terdalam. Akibatnya, pemanggilan method dapat salah diteruskan ke receiver luar tanpa kesadaran pengguna.

Bayangkan DSL HTML sederhana dengan class HtmlDoc yang memiliki method title(text) di tingkat atas dan class Body yang tidak memiliki method title. Bila pengguna menulis body { title("Dari Body") }, mereka mungkin mengira title menambahkan elemen di dalam body, padahal sebenarnya pemanggilan itu menyetel judul dokumen melalui receiver luar HtmlDoc. Kode kompilasi sah, runtime berjalan, tetapi struktur yang dihasilkan salah secara domain.

Cara Kerja @DslMarker #

Anotasi @DslMarker adalah mekanisme Kotlin untuk membatasi akses implicit receiver. Cara kerjanya: kita definisikan sebuah anotasi penanda (marker) yang dianotasi @DslMarker, lalu menerapkannya pada setiap class builder DSL. Kompilator kemudian mendeteksi bahwa beberapa class dalam hierarki receiver memiliki marker yang sama, dan hanya mengizinkan akses implisit ke receiver terdalam. Akses ke receiver luar dengan marker yang sama akan ditolak.

kotlin Anatomi @DslMarker
@DslMarker
annotation class HtmlDsl

@HtmlDsl
class HtmlDoc { fun title(text: String) { /* ... */ } }

@HtmlDsl
class Body { fun h1(text: String) { /* ... */ } }

Pada kode di atas, baik HtmlDoc maupun Body diberi marker @HtmlDsl. Saat pengguna berada di dalam body { }, kompilator mengizinkan akses implisit hanya ke Body. Method title yang ada pada HtmlDoc tidak dapat dipanggil implisit — pengguna harus menulis receiver eksplisit bila benar-benar ingin melakukannya.

Contoh Sebelum dan Sesudah @DslMarker #

Mari kita lihat perbandingan langsung. Versi pertama tidak menggunakan @DslMarker, sehingga pemanggilan title("Dari Body") dari dalam body { } lolos ke kompilator dan diam-diam mengubah docTitle receiver luar. Versi kedua menggunakan @DslMarker, sehingga kompilator menolak kode yang sama dengan pesan kesalahan yang jelas.

kotlin
// dsl_with_annotations.kt
// DSL dengan @DslMarker: membatasi akses implicit receiver
// agar DSL bersarang tidak salah menempatkan elemen.

// ===== Versi 1: Tanpa @DslMarker — receiver luar tetap dapat diakses implisit =====
// Skenario: method `title(text)` hanya ada pada HtmlDocPlain (receiver terluar).
// Pemanggilan `title(...)` dari dalam `body { }` seharusnya tidak masuk akal
// (judul dokumen tidak boleh diatur dari dalam body), namun kompilator menerima
// karena body block memiliki akses implisit ke receiver luar HtmlDocPlain.
class HtmlDocPlain {
    val parts = mutableListOf<String>()
    var docTitle: String = ""

    // Method title ada di receiver terluar — seharusnya hanya dipanggil
    // di top-level html block, bukan dari dalam body atau head.
    fun title(text: String) { docTitle = text }

    fun head(block: HeadPlain.() -> Unit) {
        val h = HeadPlain()
        h.block()
        parts.add("head: ${h.content}")
    }

    fun body(block: BodyPlain.() -> Unit) {
        val b = BodyPlain()
        b.block()
        parts.add("body: ${b.items.joinToString(", ")}")
    }
}

class HeadPlain {
    var content: String = ""
    fun meta(name: String) { content += "[$name]" }
}

class BodyPlain {
    val items = mutableListOf<String>()
    fun h1(text: String) { items.add("h1:$text") }
    fun p(text: String) { items.add("p:$text") }
}

fun htmlPlain(block: HtmlDocPlain.() -> Unit): HtmlDocPlain {
    val doc = HtmlDocPlain()
    doc.block()
    return doc
}

// ===== Versi 2: Dengan @DslMarker — akses receiver luar dibatasi =====
@DslMarker
annotation class HtmlDsl

@HtmlDsl
class HtmlDoc {
    val parts = mutableListOf<String>()
    var docTitle: String = ""

    fun title(text: String) { docTitle = text }

    fun head(block: Head.() -> Unit) {
        val h = Head()
        h.block()
        parts.add("head: ${h.content}")
    }

    fun body(block: Body.() -> Unit) {
        val b = Body()
        b.block()
        parts.add("body: ${b.items.joinToString(", ")}")
    }
}

@HtmlDsl
class Head {
    var content: String = ""
    fun meta(name: String) { content += "[$name]" }
}

@HtmlDsl
class Body {
    val items = mutableListOf<String>()
    fun h1(text: String) { items.add("h1:$text") }
    fun p(text: String) { items.add("p:$text") }
}

fun htmlDsl(block: HtmlDoc.() -> Unit): HtmlDoc {
    val doc = HtmlDoc()
    doc.block()
    return doc
}

fun main() {
    // ===== Demo 1: Tanpa @DslMarker — bug diam-diam lolos =====
    // Di dalam body block, pemanggilan title("Dari Body") sebenarnya
    // mengakses method title() pada receiver luar HtmlDocPlain.
    // Hasilnya: docTitle diubah dari dalam body, padahal seharusnya
    // title hanya boleh diatur di top-level.
    val rusak = htmlPlain {
        // Memanggil title di top-level — wajar.
        title("Dokumen Budi")
        body {
            // BUG: title di sini menyetel docTitle, BUKAN menambah child ke body.
            // Pengguna mungkin mengira ini menambah elemen <title> di body,
            // padahal ia mengubah docTitle receiver luar.
            title("Dari Body")
            h1("Halo")
            p("Paragraf")
        }
    }
    println("=== Tanpa @DslMarker (bug diam-diam) ===")
    println("docTitle = ${rusak.docTitle}")
    println("parts    = ${rusak.parts}")

    // ===== Demo 2: Dengan @DslMarker — kode salah ditolak kompilator =====
    // Bila komentar pada baris `title("Dari Body")` dibuka, kompilator menolak:
    //   error: 'fun title(text: String): Unit' cannot be called in this
    //   context with implicit receiver. Use an explicit receiver if necessary.
    //
    // Karena Body dan HtmlDoc sama-sama diberi @HtmlDsl, kompilator hanya
    // mengizinkan akses implisit ke receiver terdalam (Body). Method `title`
    // yang ada di HtmlDoc (outer) tidak dapat dipanggil implisit dari Body.
    val aman = htmlDsl {
        title("Dokumen Siti")   // OK: di top-level, receiver terdalam adalah HtmlDoc.
        head {
            meta("viewport")
        }
        body {
            // title("Dari Body")  // <-- bila baris ini dibuka: KOMPILASI GAGAL
            h1("Halo")
            p("Paragraf")
        }
    }
    println()
    println("=== Dengan @DslMarker (aman) ===")
    println("docTitle = ${aman.docTitle}")
    println("parts    = ${aman.parts}")

    // ===== Catatan: akses eksplisit tetap diizinkan =====
    // Jika pengguna benar-benar perlu mengakses receiver luar, ia dapat
    // menulis receiver eksplisit. Namun, untuk DSL yang baik, struktur
    // nested seharusnya tidak memerlukan akses seperti itu.
    println()
    println("=== Verifikasi batasan @DslMarker ===")
    println("Bila `title(\"Dari Body\")` di dalam body{} dibuka, kompilator menolak")
    println("dengan pesan: 'cannot be called in this context with implicit receiver.'")
}

Output yang Dihasilkan:

text
=== Tanpa @DslMarker (bug diam-diam) ===
docTitle = Dari Body
parts    = [body: h1:Halo, p:Paragraf]

=== Dengan @DslMarker (aman) ===
docTitle = Dokumen Siti
parts    = [head: [viewport], body: h1:Halo, p:Paragraf]

=== Verifikasi batasan @DslMarker ===
Bila `title("Dari Body")` di dalam body{} dibuka, kompilator menolak
dengan pesan: 'cannot be called in this context with implicit receiver.'

Mari kita telaah baris-baris kode di atas:

  1. Baris 10 (class HtmlDocPlain): class builder untuk versi tanpa @DslMarker. Method title(text) didefinisikan di sini, sehingga dari dalam body { } pemanggilan title(...) akan diteruskan ke receiver luar HtmlDocPlain.title. Inilah celah yang menyebabkan bug diam-diam.
  2. Baris 16 (fun title(text: String) { docTitle = text }): method ini menyetel docTitle milik dokumen. Secara domain, ini seharusnya hanya dipanggil di top-level. Namun tanpa @DslMarker, pemanggilan dari dalam body { } tetap sah.
  3. Baris 26 (fun body(block: BodyPlain.() -> Unit)): parameter block bertipe receiver function type BodyPlain.() -> Unit. Di dalam block, this adalah instance BodyPlain. Namun, receiver luar HtmlDocPlain tetap aktif secara implisit.
  4. Baris 73 (val rusak = htmlPlain { ... }): pemanggilan DSL dengan bug diam-diam. Setelah block selesai, docTitle bernilai "Dari Body" — pengguna mungkin mengira title("Dokumen Budi") di top-level sudah cukup, tetapi pemanggilan title("Dari Body") di dalam body menimpa nilai tersebut.
  5. Baris 92 (@DslMarker): anotasi penanda yang diterapkan pada annotation class HtmlDsl. Anotasi ini memberi sinyal ke kompilator bahwa setiap class yang dianotasi @HtmlDsl adalah bagian dari DSL yang sama dan harus mengikuti aturan receiver restriktif.
  6. Baris 95 (@HtmlDsl class HtmlDoc): class builder untuk versi dengan @DslMarker. Struktur class identik dengan HtmlDocPlain, tetapi anotasi @HtmlDsl memberikan batasan akses yang ketat.
  7. Baris 110 (@HtmlDsl class Head) dan 128 (@HtmlDsl class Body): kedua class builder ini juga diberi @HtmlDsl. Karena ketiga class (HtmlDoc, Head, Body) berbagi marker yang sama, kompilator memperlakukan mereka sebagai satu keluarga DSL dan membatasi akses implisit ke receiver terdalam saja.
  8. Baris 144 (title("Dokumen Siti")): di top-level, receiver terdalam adalah HtmlDoc, sehingga title dapat dipanggil dengan sah. Inilah cara pemanggilan yang benar.
  9. Baris 150 (// title("Dari Body") // <-- bila baris ini dibuka: KOMPILASI GAGAL): baris komentar ini menunjukkan kode yang akan ditolak kompilator. Pesan kesalahan kompilator kira-kira: 'fun title(text: String): Unit' cannot be called in this context with implicit receiver. Use an explicit receiver if necessary.

Pesan Kesalahan Kompilator #

Saat @DslMarker menolak akses, pesan kesalahan kompilator biasanya berbunyi:

text
error: 'fun title(text: String): Unit' cannot be called in this context with implicit receiver. Use an explicit receiver if necessary.

Pesan ini cukup informatif: kompilator memberi tahu bahwa pemanggilan ditolak dan menyarankan penggunaan receiver eksplisit. Ini berbeda jauh dari bug diam-diam versi tanpa @DslMarker yang menghasilkan struktur salah tanpa pesan apa pun.

Kapan @DslMarker Tidak Diperlukan #

@DslMarker berguna saat DSL memiliki beberapa level receiver yang se-marker. Bila DSL hanya memiliki satu level (misalnya buildList { add(...) }), tidak ada masalah receiver luar sehingga @DslMarker tidak memberi manfaat tambahan. Demikian pula, bila setiap level receiver adalah tipe yang berbeda tanpa marker, akses implisit sudah terbatas karena method satu tipe tidak ada di tipe lain — meskipun demikian, tetap ada risiko bila sebuah method kebetulan ada di kedua tipe.

Referensi Lanjutan #

  • @DslMarker — dokumentasi API resmi untuk anotasi @DslMarker
  • Type-safe builders — panduan resmi yang menjelaskan peran @DslMarker dalam membangun DSL type-safe
  • Scope functions — scope function seperti apply menggunakan pola receiver lambda yang sama; @DslMarker tidak diperlukan di sana karena hanya satu level receiver

Penutup #

@DslMarker menutup celah keamanan DSL bersarang dengan biaya minimal. Kita telah melihat bagaimana tanpa marker, pemanggilan method pada receiver luar dapat lolos dan menghasilkan struktur salah secara diam-diam; dengan marker, kompilator menolak kode tersebut sejak kompilasi. Pola ini menjadi standar de facto untuk DSL Kotlin modern. Pada chapter berikutnya, Internal DSL Design, kita akan menyatukan semua konsep — extension function, infix notation, operator overloading, dan property delegate — untuk merancang DSL SQL-like yang siap dipakai di aplikasi nyata.