DSL type-safe builders
This is a chapter from the book Functional Kotlin. You can find it on LeanPub or Amazon. It is also available as a course.
There is a trend in programming: we like to move different kinds of definitions into the codebase. A well-known example is a build-tool configuration. It used to be standard practice to write such configurations in XML in build tools like Ant or Maven. Gradle, which can be considered a successor of Maven, defines its configuration in code. The build.gradle
files that you might have seen in projects are just Groovy code:
// build.gradle
// Groovy
plugins {
id 'java'
}
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
implementation "org.jb.ktx:kotlinx-coroutines-core:1.6.0"
testImplementation "io.mockk:mockk:1.12.1"
testImplementation "org.junit.j:junit-jupiter-api:5.8.2"
testRuntimeOnly "org.junit.j:junit-jupiter-engine:5.8.2"
}
Some dependencies are shortened to match book width.
Defining configurations in code makes working with them more convenient. First, this kind of environment is known to developers, so they know what they can and cannot do. It is possible to define helper functions, classes, use lambda expressions etc. However, Gradle was not completely satisfied with Groovy: it is too dynamic, suggestions practically don't work, and we often have no information about typos. These are the reasons why the new approach to define Gradle configurations is to use Kotlin:
In this chapter we will learn about the features that are used in the above code. When we write this code, at every point we can use concrete structures that were defined by the designer of this configuration API. This is why it is called a Domain Specific Language (DSL): creators define a small language that is specifically designed to describe something concrete using code, which in this case is a Gradle configuration.
The motivation behind defining Domain-Specific Languages (DSLs) is to achieve fluent grammar when describing things and actions.
DSLs revolutionized how we define views on frontend applications. I believe that the biggest game-changer was React (a JavaScript library), which allowed us to define HTML in JavaScript. However, with Kotlin DSLs we can also implement React applications in Kotlin, and we can also define HTML for backend applications in Kotlin.
This approach also inspired other communities. At the time of writing this book, it is becoming standard practice to define iOS views using SwiftUI, which uses Swift DSL under its hood, and Android views are often defined using JetPack Compose, which uses Kotlin DSL1.
The situation with desktop applications is similar. Here is a view defined using TornadoFX, which is built on top of JavaFX:
DSLs are also used on the backend. For example, Ktor framework API is based on Kotlin DSL. Thanks to that, endpoint definitions are simple and readable but also flexible and convenient to use.
DSL-based frameworks are also much more elastic than annotation-based ones. For instance, you can easily define several endpoints based on a list or map.
DSLs are considered highly readable, so more and more libraries use DSL-styled configurations instead of builders for their configurations.
DSLs are also used by some testing libraries. This is what an example test defined in Kotlin Test looks like:
As you can see, DSLs are already widespread, and there are good reasons for this. They make it easy to define even complex and hierarchical data structures. Inside these DSLs, we can use everything that Kotlin offers, and we also have useful hints. It is likely that you have already used some Kotlin DSLs, but it is also important to know how to define them yourself. Even if you don’t want to become a DSL creator, you’ll become a better user.
A function type with a receiver
To understand how to make your own DSLs, it is important to understand the feature called function type with a receiver, which is a function type that represents an extension function.
I believe a good way to introduce a function type with a receiver is by starting with concepts we already know. In the Anonymous functions chapter, I explained that anonymous functions are defined like regular functions, but without names. In the same way, we can define an anonymous extension function. The result of such a function is also of a function type, but not a regular one, because it can be called in a special way: on a receiver.
So, we have an object that represents an extension function. It needs to have a type, but this type needs to be different from a type that represents a regular function. Yes, it needs to be a function type with a receiver.
We construct function types with a receiver the same way as regular function types, but they additionally define their receiver type:
User.() -> Unit
- a type representing an extension function onUser
that expects no arguments and returns nothing significant.Int.(Int) -> Int
- a type representing an extension function onInt
that expects a single argument of typeInt
and returnsInt
.String.(String, String) -> String
- a function type representing an extension function onString
that expects two arguments of typeString
and returnsString
.
The function stored in myPlus2
is an extension function on String
; it expects a single argument of type String
and returns String
, so its function type is String.(String) -> String
.
So, we know how to use anonymous extension functions, but now what we need is to define lambda expressions that represent extension functions. There is no special syntax for this. When a lambda expression is typed as a function type with receiver, it becomes a lambda expression with a receiver; as a result, it has an additional receiver inside its body (the this
keyword).
Simple DSL builders
The fact that lambda expressions with receivers change the meaning of this
can help us introduce more convenient syntax to define some object properties. Imagine that you need to deal with classic JavaBeans objects: the initialized classes are empty, so we need to set all their properties using setters. These used to be quite popular in Java, and we can still find them in a variety of libraries. As an example, let's take a look at the following dialog3 definition:
Referencing the dialog
variable with every property we want to set is not very convenient. So, let's use a trick: if we use a lambda expression with a receiver of type Dialog
, we can reference these properties implicitly because this
can be used implicitly.
The code above got a bit complicated, but we can extract the repetitive parts into a function, like showDialog
.
Now our function that shows a dialog is minimalistic and convenient. It is easy to understand how we set each property. We also have nice suggestions inside a function type with a receiver. This is our simplest DSL example.
Using apply
Instead of defining showDialog
ourselves, we could use the generic apply
function, which is an extension function on any type. It helps us create and call a function type with a receiver on any object we want.
In our case, we could just create a Dialog
, apply all modifications, and then explicitly show it.
This is a better solution if showing a dialog is not repetitive code for us and we do not want to define the showDialog
function. However, apply
helps only in simple cases, and it is not enough for more complex multi-level object definitions.
Nevertheless, we will find apply
useful for DSL definitions. We can simplify showDialog
by using it to call init
.
Simple DSL-like builders
Kotlin offers many simple builders for basic objects like collections or strings, that are built based on the concepts presented in this chapter. For example, buildList
is a function from Kotlin stdlib used to create a list by adding elements implicitly to receiver.
The idea is that we can call add
implicitly on the receiver, which is a MutableList
. This is how this function could be implemented:
Such simple function can be really useful, especially when list elements need to be added conditionally or in a loop. For example, in the book Kotlin Essentials I present an exercise to implement function secondsToPrettyTime
, that displays time in appropriate format. One of its simplest solutions uses buildList
function:
Another popular function is buildString
, where receiver is of type StringBuilder
. It is used to create a string by appending elements implicitly to receiver.
In Kotlin stdlib, there are also buildMap
and buildSet
functions, and external libraries offer similar functions for constructing other types of objects, so it is worth remembering this pattern.
Multi-level DSLs
Let's say that our Dialog
has been refactored, and there is now a class that stores button properties:
Now our showDialog
is not enough because we need to create the buttons in the classic way:
However, we could apply the same trick as before, but this time to create buttons. We could make a small DSL for this.
This is better, but it’s still not perfect. The user of our DSL needs to know that there is a makeButton
function that is used to create a button. In general, we prefer to require users to remember as little as possible. Instead, we could make okButton
and cancelButton
methods inside Dialog
to create buttons. Such functions are easily discoverable and their usage is really readable.
DslMarker
Our DSL builder for defining dialogs has one safety concern that we need to fix: by default, you can implicitly access elements from the outer receiver. In our example, this means that we can accidentally set the dialog title
inside of okButton
.
This is an inconvenience because when you ask for suggestions inside okButton
, elements will be suggested that should not be used. This also makes it easy to make a mistake.
To prevent these problems, we should use the DslMarker
meta-annotation. A Meta-annotation is an annotation to an annotation class; so, to use DslMarker
, we need to define our own annotation. In this case, we might call it DialogDsl
. When we add this annotation before classes used in our DSL, it solves our safety problem4. When we use it to annotate builder methods, it colors those functions’ calls.
As you might notice in the above image, DSL calls now have a different color (to me, it looks like burgundy). This color should be the same no matter which computer I start this code with. At the same time, DSLs can have one of four different colors that are specified in IntelliJ, and the style is chosen based on the hash of the DSL’s annotation name. So, if you rename DialogDsl
to something else, you will most likely change the color of this DSL function call.
With DslMarker
, we have a complete DSL example. Nearly all DSLs can be defined in the same way. To make sure we understand this completely, we will analyze a slightly more complicated example.
A more complex example
Previously, we built a DSL from bottom to top, but now we will go in the other direction and start with how we want our DSL to look. We will build a simple HTML DSL that defines some HTML with a header and a body with some text elements. In the end, we would like to support the following notation:
You can challenge yourself and try to implement it by yourself. I will start from the top, where the html { ... }
is. What is that? This is a function call with a lambda expression that is used as an argument.
head
and body
only make sense inside html
lambda expression, so they need to be called on its receiver. We will define them inside HtmlBuilder
. Since they have children, they will have receivers: HeadBuilder
and (my favorite) BodyBuilder
.
Inside head
, we can specify the title using a setter. So, HeadBuilder
should have a title
property. It also needs a function style
in order to specify a style.
The situation is similar with body
, which needs h1
and h3
methods. But what is +"Some text 1"
? This is the unary plus operator on String
2. It’s strange, but we need it. A plain value would not work because we need a function call to add a value to a builder. This is why it’s become so common to use the unaryPlus
operator in such cases.
With all these elements, our DSL definition shows no compilation errors; however, it’s not yet functional because the functions are still empty. We need them to store all the values somewhere. For the sake of simplicity, I will store everything in the builder we just defined.
In HeadBuilder
, I just need to store the defined styles. We will use a list.
In BodyBuilder
, we need to keep the elements in order, so I will store them in a list, and I will use a dedicated classes to represent each view element type.
In head
and body
, we need to do the same as we previously did in makeButton
. There are typically three steps:
- Create an empty builder.
- Fill it with data using the
init
function. - Store it somewhere.
So, head
could be implemented like this:
This can be simplified with apply
. In head
and body
we store data in HtmlBuilder
. In html
we need to return the builder.
Now our builders collect all the data defined in the DSL. We can just parse it and make HTML text. Here is a complete example in which the DslMarker
and toString
functions present our HTML as text.
When should we use DSLs?
DSLs give us a way to define information. DSLs can be used to express any kind of information you want, but it is never clear to users how exactly this information will be later used. In Jetpack Compose, Anko, TornadoFX or HTML DSL, we trust that the view will be correctly built based on our definitions, but it is often hard to track exactly how this happens. DSLs are hard to debug, and their usage might confuse developers who are not used to them. How they are defined can be a cost - in both developer confusion and performance. DSLs are overkill when we can use other simpler features instead. However, they are really useful when we need to express:
- complicated data structures,
- hierarchical structures,
- a huge amount of data.
I remember a project that needed AD campaigns configuration. It initially defined them in a YAML file, but later they transformed it into a DSL. They did that to use code to define rules for when ads should be shown. As a benefit, they gave users better suggestions and flexibility. I could see sets of campaigns defined in a for-loop. YAML files shine for simple configurations, but DSLs have much more to offer for more complex cases.
Everything can be expressed without a DSL-like structure by using builders or just constructors. DSLs are about boilerplate elimination of such structures. You should consider using a DSL when you see repeatable boilerplate code and there are no simpler Kotlin features that can help.
Summary
A Domain Specific Language is a structure that defines a special language inside a language. Kotlin has features that allow us to make type-safe, readable, and easy-to-use DSLs, which can simplify creating complex objects or hierarchies like HTML code or configurations. On the other hand, DSL implementations might be confusing or difficult for new developers, and they are hard to define. This is why they should only be used when they offer real value. This is also why they are also preferably defined in libraries rather than in applications. It is not easy to make a good DSL, but a well-defined DSL can make our project much better.
Jetpack Compose looks a bit different than a typical Kotlin DSL because some of its elements are added under the hood by the compiler plugin, and this process is based on annotations.
Operators are better described in Kotlin Essentials, Operators chapter.
A dialog (as Wikipedia explains) is a graphical control element in the form of a small window that communicates information to the user and prompts for a response.
Concretely, when it is used as a receiver in a function type with a receiver, then it can only be used implicitly when it is the most inner receiver (so when it is an outer receiver, it needs to be used with an explicit this@label
).