Kotlin DSLs
on Kotlin
Motivation
You start with some class that requires setting some configuration.
As you migrated from Java to Kotlin, you have already replaced setName("blah")
with name = "blah"
, but you feel there is still something missing… there is still something too boilerplate about it all.
So you decide to give Kotlin DSLs a shot.
Preparation
We’ll start with auto-generating an empty project.
malachi@enki:~/work$ mkdir ktDsl
malachi@enki:~/work$ cd ktDsl
malachi@enki:~/work/ktDsl$ gradle init --dsl kotlin
Starting a Gradle Daemon, 2 incompatible and 1 stopped Daemons could not be reused, use --status for details
Select type of project to generate:
1: basic
2: application
3: library
4: Gradle plugin
Enter selection (default: basic) [1..4] 2
Select implementation language:
1: C++
2: Groovy
3: Java
4: Kotlin
5: Swift
Enter selection (default: Java) [1..5] 4
Project name (default: ktDsl):
Source package (default: ktDsl): com.malachid.ktdsl
BUILD SUCCESSFUL in 16s
2 actionable tasks: 2 executed
And validate that it works.
malachi@enki:~/work/ktDsl$ ./gradlew run
Starting a Gradle Daemon, 2 incompatible and 2 stopped Daemons could not be reused, use --status for details
> Task :run
Hello world.
BUILD SUCCESSFUL in 14s
2 actionable tasks: 2 executed
Creating our DSL
What kind of DSL are we going to model? Let’s do something simple.
How about website bookmarks?
Our model will assume:
- There is a top-level folder
- Folders can be nested
- Each folder has a label
- Each folder has a list of bookmarks
- Each bookmark has a label and a url
DSL Marker
First, we need to define our DSL Marker.
You’ll create a BookmarkDsl.kt
. For this sample, I kept everything in the same package, but you can organize them as you see fit.
This class will contain a single annotation class, with an annotation tag on it; like so:
package com.malachid.ktdsl
@DslMarker
annotation class BookmarkDsl
Bookmark.kt
You might be wondering why we jumped to the bottom of the list.
It’s easier to define a bookmark which has no dependencies, than to define a folder that depends on the bookmark.
We’ll create a new empty Bookmark.kt
.
In this file, we will define our data class as well as our builder for that data class.
Bookmark data class
First up is our data class.
Inside of Bookmark.kt
add the new Bookmark
data class.
data class Bookmark(
val label: String,
val url: String
)
You might be wondering why we are using String instead of URL or URI or some other class for the url. No particular reason - just to keep this sample easy.
That’s it - we now have our data class that represents a single Bookmark. Over time you could add things like last visited, caching options, etc - but for now, let’s move on.
BookmarkBuilder
The next piece is the BookmarkBuilder
. This class also goes inside Bookmark.kt
.
Let’s build this one up one step at a time.
Basic class
First, our basic class.
class BookmarkBuilder {
}
Variables
Then, we want a variable for each parameter we need to pass to the data class.
var label: String = ""
var url: String = ""
A couple of things to note here:
- While the data class is using
val
, we are usingvar
in the builder. This is to allow the value to change. - The data class doesn’t allow null values, so we are setting (somewhat stupid and unvalidated) values here to make sure they aren’t null.
- Currently they will allow assignment. If you only want to allow lambda setters (next), then make them
private
.
Lambda Functions
Now, we add some lambda functions to set them.
fun label(lambda: () -> String) { label = lambda() }
fun url(lambda: () -> String) { url = lambda() }
Builder
Next, we add a build function to return a copy of the data class based on our internal variables.
fun build() = Bookmark(
label = label,
url = url
)
Annotation
And we add our annotation
@BookmarkDsl
class BookmarkBuilder {
var label: String = "a bookmark"
var url: String = ""
fun label(lambda: () -> String) { label = lambda() }
fun url(lambda: () -> String) { url = lambda() }
fun build() = Bookmark(
label = label,
url = url
)
}
Public Function
To make it easier to use, we will also make a public function to call the builder.
fun bookmark(lambda: BookmarkBuilder.() -> Unit) = BookmarkBuilder().apply(lambda).build()
It may look a little scary, but what this is doing is allowing you to pass a lambda to a bookmark and have the BookmarkBuilder use it to build a Bookmark data class.
We’ll take a concrete look at what this looks like in a few moments.
BookmarksBuilder (note the s)
One last piece to our Bookmark.kt
. When we want a List<Bookmark>
, we want an easy way to build them without having to iterate over them.
We’ll create another builder with another public function.
@BookmarkDsl
class BookmarksBuilder {
private var bookmarks = mutableListOf<Bookmark>()
fun bookmark(lambda: BookmarkBuilder.() -> Unit) {
bookmarks.add(BookmarkBuilder().apply(lambda).build())
}
fun build() = bookmarks
}
fun bookmarks(lambda: BookmarksBuilder.() -> Unit) = BookmarksBuilder().apply(lambda).build()
The key thing you will notice here is that the internal bookmark
function looks just like our public one, except that it is adding the result to our list. A bookmarks
(plural) can contain multiple bookmark
(singular).
Folder.kt
Now, we need our Folder. Create Folder.kt
and we will follow a similar pattern.
Folder data class
Create our data class:
data class Folder(
val label: String,
val folders: List<Folder> = emptyList(),
val bookmarks: List<Bookmark> = emptyList()
)
And our builder
Variables
class FolderBuilder {
var label: String = "folder"
var folders: MutableList<Folder> = mutableListOf()
var bookmarks: MutableList<Bookmark> = mutableListOf()
}
First, the builder defines the lists as mutable. You could also use MutableMap, etc - if your data needed it.
Lambdas
fun label(lambda: () -> String) { label = lambda() }
// @TODO folders lambda
fun bookmarks(lambda: BookmarksBuilder.() -> Unit) {
bookmarks.addAll(BookmarksBuilder().apply(lambda).build())
}
We’ll come back to the middle one. We’ll need to write the plural version of FolderBuilder just like we did with the Bookmarks.
Builder function
fun build() = Folder(
label = label,
folders = folders,
bookmarks = bookmarks
)
Public function
fun folder(lambda: FolderBuilder.() -> Unit) = FolderBuilder().apply(lambda).build()
Annotation
Don’t forget to annotate the class
@BookmarkDsl
class FolderBuilder {
And the plural version…
Just like we did with the BookmarksBuilder…
@BookmarkDsl
class FoldersBuilder {
private var folders = mutableListOf<Folder>()
fun folder(lambda: FolderBuilder.() -> Unit) {
folders.add(FolderBuilder().apply(lambda).build())
}
fun build() = folders
}
fun folders(lambda: FoldersBuilder.() -> Unit) = FoldersBuilder().apply(lambda).build()
Wire it up
Now we need to fix our previous TODO placeholder.
fun folders(lambda: FoldersBuilder.() -> Unit) {
folders.addAll(FoldersBuilder().apply(lambda).build())
}
Trying it out
Now that we have our DSL in place (admittedly without any documentation or tests) let’s try it.
Open App.kt
and inside the main
block, let’s call our new builders.
val rootFolder = folder {
label = "Root Folder"
bookmarks {
bookmark {
label = "Malachi's Blog"
url = "https://www.malachid.com/"
}
bookmark {
label { "Malachi's Resume" }
url { "https://www.malachid.com/resume/" }
}
}
folders {
folder {
label = "Social"
bookmarks {
bookmark {
label = "LinkedIn"
url = "https://www.linkedin.com/in/malachid"
}
bookmark {
label = "GitHub"
url = "https://github.com/malachid"
}
}
}
folder {
label = "Misc"
}
}
}
Obviously, you can use your own folder and bookmark information.
A few things you will notice.
- To the right side of each
{
, if you are in a JetBrains IDE, it will tell you which builder you are actually using. - The first bookmark uses assignment
=
operator while the second one uses the{ ... }
lambda operator. The first one is able to be called because we didn’t make the builder variables private. - There are no commas between elements
Visualizing Results
Let’s try to visualize our results by adding some pretty printing.
Create a new Printer.kt
class. We’ll just do some simple padded printing for now.
package com.malachid.ktdsl
private fun pad(num: Int) = repeat(num) { print(" ") }
fun Bookmark.print(pad: Int = 0) {
pad(pad)
println("* [$label]($url)")
}
fun Folder.print(pad: Int = 0) {
pad(pad)
println("- $label")
bookmarks.forEach { it.print(pad + 2) }
folders.forEach { it.print(pad + 2) }
}
Then back in App.kt
, call rootFolder.print()
. The output should look something like this:
- Root Folder
- Malachi’s Blog
- Malachi’s Resume
- Social
- Misc