DSL Construction

Kotlin

2026-08-21 09:00

Where we are

The last unit builds nothing new

Extension functions and infix calls — unit 3.

Lambdas — unit 5. Operator conventions — unit 7. Higher-order functions — unit 8.

It assembles.

The contrast with last unit

Reflection achieved flexibility at runtime, giving up static checking.

DSLs achieve expressiveness at compile time, keeping every guarantee.

A taste of the goal

html { body { p { +"text" } } }
"kotlin" should startWith("kot")
1.days.ago
Country.select { Country.name eq "Kotlin" }

None is a string. None is parsed at runtime.

Each is ordinary Kotlin, type-checked, with IDE completion.

What you will be able to do

  1. Define a DSL and distinguish internal from external.
  2. Explain what gives a DSL structure.
  3. Use a lambda with a receiver.
  4. Build a nested DSL.
  5. Use the invoke convention.
  6. Combine infix, extensions and member extensions.
  7. Read real Kotlin DSLs.
  8. Judge when a DSL is warranted.

The honest caveat, up front

A DSL is a vocabulary readers must learn.

Sometimes an excellent bargain.

Sometimes a plain function would have been kinder.

From APIs to DSLs

What a DSL is

A general-purpose language does everything adequately.

A domain-specific one does one thing well, giving up generality.

SQL. Regular expressions.

Declarative

Describe what the result is, not how to get it.

Which is where the concision comes from —

and why a SQL engine can reorder your query.

External DSLs

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

The compiler cannot check a string.

The IDE cannot complete it. Refactoring cannot rename through it.

Internal DSLs

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

The same query, checked, completed, safely refactored.

What actually makes it a DSL

Clean API design gives readable calls.

A DSL gives structure — a grammar.

Flat versus nested

Command-query API — each call stands alone. A flat sequence.

Structured DSL — calls nest and chain.

Which is why an HTML DSL looks like HTML.

And nesting is the mechanism

Which is why the next section

is the central one in the unit.

Lambdas with receivers

The type

Small difference, large effect

build { it.name = "x"; it.value = 1 }     // (T) -> Unit
build { name = "x"; value = 1 }           // T.() -> Unit

Nested several levels deep, that is the difference between a DSL

and a pile of punctuation.

You have already used these

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

apply, with, run — all of them.

Go back and look at apply

It stops looking like a special form.

It becomes an ordinary function you could have written.

The HTML builder

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

Inside tr { }, this is a TR.

So only the functions declared on TR are available.

Two things at once

Nesting — from lambdas containing other calls.

Static checking — from the receiver type controlling what is legal inside.

Misplacing a tag is a compile error

table {
    td { }      // will not compile
}

Not something a string template can offer at any price.

Nor achievable by naming discipline. It falls out of the receiver types.

Member extensions

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

The vocabulary is available inside the block and invisible outside it.

Which is the whole technique

Receiver-scoped vocabulary.

Statically checked nesting.

The invoke convention

Making an object callable

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

bavarianGreeter("Dmitry")     // Servus, Dmitry!

Unit 7’s convention mechanism, applied to the call syntax.

You have seen it already

Function types are interfaces with an invoke method.

Which is why a lambda in a variable can be called with parentheses.

And why unit 8’s nullable function parameter needed ?.invoke().

Why a DSL wants it

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

Both, from one object.

The implementation

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

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

The general shape

invoke loosens the nesting requirement.

A block form for many items, a direct form for one —

without duplicating the API.

A caution

invoke on an arbitrary class makes calls unreadable.

person("Alice") tells the reader nothing.

It earns its place in a DSL, where structure supplies the meaning.

DSLs in practice

A reading exercise

The mechanisms are known.

The work is recognising them in the wild.

Testing frameworks

s should startWith("kot")
infix fun <T> T.should(matcher: Matcher<T>) = matcher.test(this)

An infix function combined with an extension.

Date arithmetic

val yesterday = 1.days.ago

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

Extension properties — 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)
}

Country.select { Country.name eq "Kotlin" }

Three mechanisms stacked

Member extensionsautoIncrement() exists only inside a Table.

Infix functions — eq, less, like.

Lambdas with receivers — the query block.

And it is statically typed

A renamed column is a compile error.

Not a broken query discovered in production.

What to take from this

Every one is ordinary Kotlin.

No macro system. No metaprogramming. No runtime parsing.

A small set of features composing into what looks like language design.

When a DSL is warranted

The costs are real

A vocabulary readers must learn.

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.

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

The rule of thumb

A handful of calls → write the functions.

A hundred structurally similar ones → build the DSL.

Closing the module

Look at what this unit used

Extensions and infix — unit 3.

Lambdas — unit 5. Conventions — unit 7. Higher-order functions — unit 8.

None of them was introduced for DSLs.

The argument the module has been making

Kotlin’s power is not in any individual feature — most are small —

but in how cleanly they compose.

And it generalises

A language of small, orthogonal, well-chosen features

lets its users build things its designers never specified.

That is a claim about language design, not just about Kotlin.

Summary

The seven things to carry away

  • A DSL is not a tidy API; it is a grammar.
  • Internal DSLs keep everything a string loses.
  • T.() -> Unit is the mechanism — and apply was using it all along.
  • Misplacing a tag is a compile error, because receiver types control what is legal.
  • Member extensions confine the vocabulary to the block.
  • invoke is unit 7’s convention applied to the call itself.
  • The honest test is whether the reader’s job got easier.

And the module’s claim

Small features that compose

beat big features that do not.