Lecture notes — DSL Construction

Published

2026-08-21 00:00

Keywords

ver. 1.0.0

← DSL Construction

Where we are

This is the last unit of the module, and it is deliberately placed there: almost every mechanism it uses has already been covered.

Extension functions and infix calls from unit 3, lambdas from unit 5, operator conventions from unit 7, higher-order functions from unit 8 — and one substantial new idea, lambdas with receivers, previewed in unit 5. Put together, they let an API read like a language designed for the problem at hand.

The contrast with the previous unit is the point. Reflection achieved flexibility at runtime, by giving up static checking. DSLs achieve expressiveness at compile time, keeping every guarantee.

What you will be able to do

  1. define-a-dsl — Define what a domain-specific language is and distinguish internal from external.
  2. explain-dsl-structure — Explain what gives a DSL structure, as opposed to a merely clean API.
  3. use-lambdas-with-receivers — Use a lambda with a receiver to give a block its own implicit this.
  4. build-a-nested-dsl — Build a nested, structured DSL such as an HTML builder.
  5. use-the-invoke-convention — Use the invoke convention to make an object callable like a function.
  6. use-infix-and-extensions-for-readability — Combine infix calls, extension functions and member extensions for readable syntax.
  7. read-real-kotlin-dsls — Read and explain real Kotlin DSLs such as test frameworks and Exposed.
  8. judge-when-a-dsl-is-warranted — Judge when a DSL is worth building and when a plain API is better.

What we will cover

  • From APIs to DSLs — what a DSL is, and what gives it structure.
  • Lambdas with receivers — the central mechanism.
  • The invoke convention — making an object callable.
  • DSLs in practice — four real ones, read for their mechanisms.
  • When a DSL is warranted — and when it is not.

A taste of the goal

By the end of this unit you will be able to build these shapes:

html { body { p { +"text" } } }              // well-formed HTML, checked
"kotlin" should startWith("kot")             // test assertions
1.days.ago                                   // date arithmetic
Country.select { Country.name eq "Kotlin" }  // SQL

None of these is a string. None is parsed at runtime. Each is ordinary Kotlin, type-checked by the compiler, with autocompletion in the IDE.

NoteThe honest caveat, up front

A DSL is a vocabulary that readers must learn.

Sometimes that is an excellent bargain. Sometimes a plain function would have been kinder. The last section of this unit returns to the question, and it is worth holding it open while you read the mechanisms.

From APIs to DSLs

What a DSL is

A general-purpose language does everything adequately. A domain-specific language does one thing well, giving up generality in exchange. SQL and regular expressions are the classic examples — neither can express a whole program, and both are far better than a general-purpose language at what they do.

DSLs tend to be declarative rather than imperative: they describe what the desired result is and leave how to the implementation. That is where the concision comes from, and also where the optimisation opportunities come from — a SQL engine can reorder a query precisely because the query did not specify a procedure.

External and internal

External DSLs are separate languages with their own grammar and parser, embedded in the host program as strings:

String sql = "SELECT * FROM Country WHERE name = 'Kotlin'";

The cost is plain: the compiler cannot check a string, the IDE cannot complete it, refactoring cannot rename through it, and errors appear at runtime.

Internal DSLs are ordinary host-language code, written so it reads like a purpose-built one:

val result = (Country join Customer)
    .slice(Country.name, Count(Customer.id))
    .selectAll()
    .groupBy(Country.name)

The same query, checked by the compiler, completed by the IDE, and refactored safely when a column is renamed.

What actually makes it a DSL

This is the sharpest idea in the section, and it is easy to miss.

Clean API design gives you readable calls. A DSL gives you structure — a grammar.

  • In a command-query API, each call stands alone. The sequence is flat: a list of independent statements.
  • In a structured DSL, calls nest and chain. The result has a shape that mirrors the domain’s own shape.

Which is why an HTML DSL looks like HTML. Nesting is what carries the structure, and it is the reason the next section — lambdas with receivers — is the central mechanism of the unit.

Learning outcomes

  • define-a-dsl: Define what a domain-specific language is and distinguish internal from external.
  • explain-dsl-structure: Explain what gives a DSL structure, as opposed to a merely clean API.

Concepts

  • domain-specific-language: generality traded for expressiveness in one domain
  • internal-dsl: host-language code that reads like a separate language, and keeps static typing
  • type-safe-builders: nesting that the compiler checks

Lambdas with receivers

An extension function type: a receiver, parameters, and a return type.

The idea

An ordinary lambda parameter has type (T) -> Unit, and the block refers to it. A lambda with a receiver has type T.() -> Unit, and inside the block the value of type T is the implicit this — so all its members can be used unqualified.

The difference looks small and is not:

build { it.name = "x"; it.value = 1 }     // ordinary lambda
build { name = "x"; value = 1 }           // lambda with receiver

Once blocks are nested several levels deep, that is the difference between a DSL and a pile of punctuation.

You have already used these

apply, with, run and buildString from unit 5 all take lambdas with receivers:

fun buildString(builderAction: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.builderAction()          // called like a method on sb
    return sb.toString()
}

Worth going back to apply with this in mind. It stops looking like a special form and becomes an ordinary function you could have written — which is the general lesson of this unit arriving early.

How the HTML builder works

fun createSimpleTable() = createHTML().
    table {
        tr {
            td { +"cell" }
        }
    }

Each tag function takes a lambda with a receiver of the corresponding tag type. Inside tr { ... }, this is a TR, so only the functions declared on TR are available. Which means:

  • the nesting comes from lambdas containing other calls
  • the static checking comes from the receiver type controlling what is legal inside
ImportantMisplacing a tag is a compile error
table {
    td { }      // will not compile — td is not a member of TABLE
}

That is a genuinely strong guarantee, and it is not something a string-based template engine can offer at any price. Nor can it be achieved by naming discipline alone — it falls out of the receiver types.

Member extensions

The confinement is sharpened by declaring extension functions inside a class, so they exist only where that class is the receiver:

class Table {
    fun Column<*>.primaryKey() { }    // available only inside a Table block
}

A member extension is visible only within the DSL’s scope. The vocabulary is available inside the block and invisible outside it, which keeps the DSL from polluting the surrounding namespace.

That combination — receiver-scoped vocabulary, statically checked nesting — is essentially the whole technique.

Learning outcomes

  • use-lambdas-with-receivers: Use a lambda with a receiver to give a block its own implicit this.
  • build-a-nested-dsl: Build a nested, structured DSL such as an HTML builder.

Concepts

  • lambdas-with-receiver: the receiver becomes this, so members need no qualification
  • extension-function-types: T.() -> Unit, the type that makes it possible
  • member-extension-functions: an extension declared inside a class, so it exists only in that scope

The invoke convention

class Greeter(val greeting: String) {
    operator fun invoke(name: String) {
        println("$greeting, $name!")
    }
}

val bavarianGreeter = Greeter("Servus")
bavarianGreeter("Dmitry")     // Servus, Dmitry!

Defining operator fun invoke makes instances callable with parentheses. obj(args) compiles to obj.invoke(args) — exactly the pattern from unit 7, where a + b became a.plus(b), now applied to the call syntax itself.

Where you have already seen it. Function types are interfaces with an invoke method, which is why a lambda stored in a variable can be called with parentheses. That was invoke all along — and it is why unit 8’s nullable function parameter needed ?.invoke().

Why a DSL wants it

Gradle’s build files are the motivating example, and they want to permit both:

dependencies { compile("junit:junit:4.11") }   // block form
dependencies.compile("junit:junit:4.11")       // direct form

The first needs an object taking a lambda with a receiver; the second needs the same object exposing methods directly. Making it callable via invoke gives both from a single object:

class DependencyHandler {
    fun compile(coordinate: String) { }

    operator fun invoke(body: DependencyHandler.() -> Unit) {
        body()
    }
}

The general shape: invoke loosens the nesting requirement, so a DSL can offer a block form for the multi-item case and a direct form for the single-item case without duplicating its API.

NoteA caution the book is right to give

invoke on an arbitrary class makes calls unreadable. person("Alice") tells the reader nothing about what happens.

It earns its place in a DSL, where the surrounding structure supplies the meaning, and rarely anywhere else.

Learning outcomes

  • use-the-invoke-convention: Use the invoke convention to make an object callable like a function.

Concepts

  • invoke-convention: unit 7’s convention mechanism applied to the call syntax itself

DSLs in practice

The most useful section in the unit, and it is a reading exercise: the mechanisms are known, so the work is recognising them in the wild.

Testing frameworks

s should startWith("kot")

An infix function combined with an extension:

infix fun <T> T.should(matcher: Matcher<T>) = matcher.test(this)

And where the framework wants assertions confined to a test scope, the infix receiver becomes a member extension — available inside the test block and nowhere else.

Date arithmetic

val yesterday = 1.days.ago
val tomorrow = 1.days.fromNow

Extension properties on Int, returning a value that carries its own further extensions:

val Int.days: Period
    get() = Period.ofDays(this)

val Period.ago: LocalDate
    get() = LocalDate.now() - this

Extending a built-in type in a scoped way, and doing something a subclass could not — you cannot subclass Int.

SQL with Exposed

object Country : Table() {
    val id = integer("id").autoIncrement().primaryKey()
    val name = varchar("name", 50)
}

val result = Country.select { Country.name eq "Kotlin" }

The mechanisms stack:

  • member extensions to define table columns — autoIncrement() and primaryKey() exist only inside a Table
  • infix functions for the comparison operators — eq, less, like
  • lambdas with receivers for the query block

And the result is statically typed. A renamed column is a compile error, not a broken query discovered in production.

Android UI construction

The fourth, and it shows the same builder pattern as the HTML DSL applied to a widget tree — nested blocks, each with the enclosing view as its receiver.

ImportantWhat to take from this section

Every one of these is ordinary Kotlin. There is no macro system, no metaprogramming, no runtime parsing.

A small set of features — extensions, infix, conventions, receiver lambdas — composes into what looks like language design.

That composability is the honest answer to why Kotlin has so many small features rather than one big one.

Learning outcomes

  • read-real-kotlin-dsls: Read and explain real Kotlin DSLs such as test frameworks and Exposed.
  • use-infix-and-extensions-for-readability: Combine infix calls, extension functions and member extensions for readable syntax.

Concepts

  • internal-dsl: four real ones, each assembled from features you already know

When a DSL is warranted

The costs are real

  • a vocabulary readers must learn before they can read the code
  • indirection between what is written and what happens
  • the standing temptation to be clever

The benefit is real too

In a domain that is repetitive and genuinely has structure — markup, queries, builds, tests, UI layout — a DSL removes noise that no amount of good naming can.

TipThe honest test

Does the DSL make the reader’s job easier?

A DSL that is delightful to write and puzzling to read has failed, however pleased its author was with it.

For a handful of calls, write the functions. For a hundred structurally similar ones, build the DSL.

Learning outcomes

  • judge-when-a-dsl-is-warranted: Judge when a DSL is worth building and when a plain API is better.
  • define-a-dsl: Define what a domain-specific language is and distinguish internal from external.
  • explain-dsl-structure: Explain what gives a DSL structure, as opposed to a merely clean API.
  • use-lambdas-with-receivers: Use a lambda with a receiver to give a block its own implicit this.
  • build-a-nested-dsl: Build a nested, structured DSL such as an HTML builder.
  • use-the-invoke-convention: Use the invoke convention to make an object callable like a function.
  • use-infix-and-extensions-for-readability: Combine infix calls, extension functions and member extensions for readable syntax.
  • read-real-kotlin-dsls: Read and explain real Kotlin DSLs such as test frameworks and Exposed.

Closing the module

Look back at what this last unit used.

Extension functions and infix calls from unit 3. Lambdas from unit 5. Operator conventions from unit 7. Higher-order functions and inlining from unit 8. None of them was introduced for DSLs. Each was a modest, self-contained convenience at the time.

That is the argument the whole module has been making, and this unit is where it lands:

Kotlin’s power is not in any individual feature — most are small — but in how cleanly they compose. A language of small, orthogonal, well-chosen features lets its users build things its designers never specified.

That is a claim about programming language design, not just about Kotlin, and it is worth carrying into the rest of the course.

Conclusion

  • A DSL is not a tidy API; it is a grammar.

    Clean naming gives readable calls. Nesting and chaining give structure that mirrors the domain — which is why an HTML DSL looks like HTML and a query DSL looks like a query.

  • Internal DSLs keep everything a string loses.

    Compile-time checking, IDE completion, safe refactoring. The Exposed-versus-JDBC comparison is the whole argument in two code samples.

  • T.() -> Unit is the mechanism, and apply was using it all along.

    The receiver becomes this, so members need no qualification — and because each block’s receiver type controls what is legal inside, misplacing a tag is a compile error.

  • Member extensions confine the vocabulary to the block.

    An extension declared inside a class exists only where that class is the receiver. The DSL’s words are available inside and invisible outside.

  • invoke is unit 7’s convention applied to the call itself.

    It lets one object serve as both a block and a direct call, which is how Gradle offers dependencies { } and dependencies.compile(...) from the same handler.

  • The honest test is whether the reader’s job got easier.

    Not the author’s. A DSL is a vocabulary others must learn, and it earns that cost only in a repetitive domain with real structure.

  • The module’s real claim is about composition.

    Extensions, infix, conventions, receiver lambdas — none designed for DSLs, all combining into one. Small orthogonal features let users build what the designers never specified.