Skip to main content

Kotlin Custom DSLs

Introduction

Domain-Specific Languages (DSLs) are specialized programming languages designed to solve problems in a specific domain. In Kotlin, you can create your own DSL to make your code more expressive, readable, and maintainable. Unlike general-purpose programming languages, a DSL focuses on a particular domain, making it more accessible to domain experts who might not be coding experts.

Kotlin's language features make it exceptionally good for creating DSLs, also known as "internal" DSLs (as opposed to "external" DSLs that require a separate parser). These features include:

  • Extension functions
  • Lambda expressions with receivers
  • Operator overloading
  • Infix notation
  • Function literals with receivers

In this tutorial, we'll learn how to create custom DSLs in Kotlin from scratch.

Understanding DSL Basics

Before diving into the code, let's understand what makes a good DSL:

  1. Readability: The code should be easily understandable, even by non-programmers familiar with the domain.
  2. Expressiveness: The DSL should allow users to express complex operations concisely.
  3. Safety: Type checking and compile-time verification are important advantages of internal DSLs.

Creating Your First Simple DSL

Let's start with a simple example: a DSL for building an HTML document. We want to write something like this:

kotlin
html {
head {
title("My First Kotlin DSL")
}
body {
h1("Welcome")
p("This is a paragraph created using Kotlin DSL")
a(href = "https://kotlinlang.org") {
+"Visit Kotlin Website"
}
}
}

To make this possible, we need to define several classes and functions:

kotlin
class HTML {
var head: Head? = null
var body: Body? = null

fun head(init: Head.() -> Unit) {
val head = Head()
head.init()
this.head = head
}

fun body(init: Body.() -> Unit) {
val body = Body()
body.init()
this.body = body
}

override fun toString(): String {
return "<html>\n ${head}\n ${body}\n</html>"
}
}

class Head {
var title: String = ""

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

override fun toString(): String {
return "<head>\n <title>$title</title>\n </head>"
}
}

class Body {
val elements = mutableListOf<Element>()

fun h1(text: String) {
elements.add(Header1(text))
}

fun p(text: String) {
elements.add(Paragraph(text))
}

fun a(href: String, init: Anchor.() -> Unit) {
val a = Anchor(href)
a.init()
elements.add(a)
}

operator fun String.unaryPlus() {
elements.add(TextElement(this))
}

override fun toString(): String {
return "<body>\n ${elements.joinToString("\n ")}\n </body>"
}
}

interface Element {
fun render(): String
}

class Header1(val text: String) : Element {
override fun render(): String {
return "<h1>$text</h1>"
}

override fun toString(): String = render()
}

class Paragraph(val text: String) : Element {
override fun render(): String {
return "<p>$text</p>"
}

override fun toString(): String = render()
}

class Anchor(val href: String) : Element {
val children = mutableListOf<Element>()

operator fun String.unaryPlus() {
children.add(TextElement(this))
}

override fun render(): String {
return "<a href=\"$href\">${children.joinToString("") { it.toString() }}</a>"
}

override fun toString(): String = render()
}

class TextElement(val text: String) : Element {
override fun render(): String = text

override fun toString(): String = render()
}

fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}

Now let's test our DSL:

kotlin
fun main() {
val htmlContent = html {
head {
title("My First Kotlin DSL")
}
body {
h1("Welcome")
p("This is a paragraph created using Kotlin DSL")
a(href = "https://kotlinlang.org") {
+"Visit Kotlin Website"
}
}
}

println(htmlContent)
}

Output:

<html>
<head>
<title>My First Kotlin DSL</title>
</head>
<body>
<h1>Welcome</h1>
<p>This is a paragraph created using Kotlin DSL</p>
<a href="https://kotlinlang.org">Visit Kotlin Website</a>
</body>
</html>

Breaking Down the DSL Components

Let's analyze the key components that make this DSL work:

1. Function Literals with Receivers

The most important feature we're using is function literals with receivers, often called "lambdas with receivers." For example:

kotlin
fun html(init: HTML.() -> Unit): HTML

Here, HTML.() -> Unit is a function type that has HTML as its receiver. This means inside this function, this refers to an instance of HTML.

2. Context Control

Each block in our DSL has its own context, created by extension function types. For example, within the head { ... } block, we're inside a Head context, so we can call methods defined on Head.

3. Builder Pattern

Our DSL is implementing the builder pattern, which allows us to construct complex objects step-by-step.

4. Operator Overloading

We used the unary plus operator (+) to add text elements:

kotlin
operator fun String.unaryPlus() {
elements.add(TextElement(this))
}

This allows us to write +"Visit Kotlin Website" to add text content.

Creating a More Practical DSL: Task Scheduling

Now, let's create a more practical DSL for scheduling tasks:

kotlin
// Task scheduler DSL

data class Task(
val name: String,
val priority: Int = 0,
val deadline: String? = null,
val tags: List<String> = emptyList(),
val assignee: String? = null,
val description: String? = null
)

class TaskBuilder {
var name: String = ""
var priority: Int = 0
var deadline: String? = null
private val tags = mutableListOf<String>()
var assignee: String? = null
var description: String? = null

fun tags(vararg newTags: String) {
tags.addAll(newTags)
}

fun build(): Task = Task(name, priority, deadline, tags, assignee, description)
}

class TaskSchedulerBuilder {
private val tasks = mutableListOf<Task>()

fun task(init: TaskBuilder.() -> Unit) {
val builder = TaskBuilder()
builder.init()
tasks.add(builder.build())
}

fun highPriority(init: TaskBuilder.() -> Unit) {
val builder = TaskBuilder()
builder.priority = 3
builder.init()
tasks.add(builder.build())
}

fun mediumPriority(init: TaskBuilder.() -> Unit) {
val builder = TaskBuilder()
builder.priority = 2
builder.init()
tasks.add(builder.build())
}

fun lowPriority(init: TaskBuilder.() -> Unit) {
val builder = TaskBuilder()
builder.priority = 1
builder.init()
tasks.add(builder.build())
}

fun getTasks(): List<Task> = tasks.toList()
}

fun taskScheduler(init: TaskSchedulerBuilder.() -> Unit): List<Task> {
val scheduler = TaskSchedulerBuilder()
scheduler.init()
return scheduler.getTasks()
}

Now, let's use our task scheduler DSL:

kotlin
fun main() {
val tasks = taskScheduler {
highPriority {
name = "Complete Kotlin DSL article"
deadline = "2023-08-15"
tags("kotlin", "programming", "dsl")
description = "Finish writing the article on Kotlin DSLs"
assignee = "John Doe"
}

mediumPriority {
name = "Review code changes"
deadline = "2023-08-20"
tags("code-review", "collaboration")
assignee = "Jane Smith"
}

lowPriority {
name = "Update documentation"
tags("docs", "maintenance")
}

task {
name = "Custom priority task"
priority = 5
deadline = "ASAP"
}
}

println("Tasks scheduled:")
tasks.forEachIndexed { index, task ->
println("${index + 1}. ${task.name} (Priority: ${task.priority})")
task.deadline?.let { println(" Deadline: $it") }
if (task.tags.isNotEmpty()) println(" Tags: ${task.tags.joinToString()}")
task.assignee?.let { println(" Assigned to: $it") }
task.description?.let { println(" Description: $it") }
println()
}
}

Output:

Tasks scheduled:
1. Complete Kotlin DSL article (Priority: 3)
Deadline: 2023-08-15
Tags: kotlin, programming, dsl
Assigned to: John Doe
Description: Finish writing the article on Kotlin DSLs

2. Review code changes (Priority: 2)
Deadline: 2023-08-20
Tags: code-review, collaboration
Assigned to: Jane Smith

3. Update documentation (Priority: 1)
Tags: docs, maintenance

4. Custom priority task (Priority: 5)
Deadline: ASAP

Advanced DSL Features

Type-Safe Builders

Kotlin DSLs are type-safe, which means you get compile-time error checking. This is a significant advantage over external DSLs or string-based configurations.

Scoped Functions

Kotlin's standard library includes scoped functions like apply, with, let, also, and run. These are essentially mini-DSLs that help you write more concise code.

Infix Notation

You can use infix notation to make your DSL even more readable:

kotlin
class TaskBuilder {
// ...other properties and methods

infix fun assignedTo(person: String) {
assignee = person
}
}

// Usage
task {
name = "Test infix notation"
assignedTo "Alice" // Using infix notation
}

Real-World Applications of Kotlin DSLs

Kotlin DSLs are used extensively in various frameworks and libraries:

1. Android UI with Jetpack Compose

Jetpack Compose uses a DSL approach for building UIs:

kotlin
@Composable
fun Greeting(name: String) {
Column {
Text(text = "Hello $name!")
Button(onClick = { /* do something */ }) {
Text("Click me")
}
}
}

2. Gradle Kotlin DSL

Gradle build scripts can be written in Kotlin:

kotlin
plugins {
kotlin("jvm") version "1.5.31"
}

dependencies {
implementation(kotlin("stdlib"))
testImplementation("junit:junit:4.13.2")
}

3. Exposed SQL Library

Exposed is a SQL library that uses DSL for database operations:

kotlin
object Users : Table() {
val id = integer("id").autoIncrement()
val name = varchar("name", 50)
val email = varchar("email", 100)

override val primaryKey = PrimaryKey(id)
}

// Insert data
transaction {
Users.insert {
it[name] = "John"
it[email] = "[email protected]"
}
}

Best Practices for Creating DSLs

  1. Keep it Simple: The main purpose of a DSL is to make code more readable and expressive. Avoid overly complex structures.

  2. Consistent Design: Maintain consistent naming conventions and patterns throughout your DSL.

  3. Documentation: Since DSLs often create unique syntax, good documentation is essential.

  4. Error Handling: Provide clear error messages when the DSL is used incorrectly.

  5. Scope Control: Be mindful of which functions and properties are available in each context of your DSL.

Summary

Kotlin's language features make it exceptionally well-suited for creating expressive, type-safe DSLs. By using function literals with receivers, extension functions, and other Kotlin features, we can create code that feels like a specialized language for our specific domain.

In this tutorial, we've learned:

  • The basic concepts of DSLs in Kotlin
  • How to create simple and more complex DSLs
  • The language features that enable DSL creation
  • Real-world applications of Kotlin DSLs
  • Best practices for creating your own DSLs

Exercises

  1. Extend the HTML DSL to support more HTML tags like div, span, and img.
  2. Create a DSL for configuring a simple game character with attributes like health, strength, and special abilities.
  3. Implement a DSL for building a meal plan, with functions for breakfast, lunch, and dinner, each containing food items with calories.
  4. Build a simple DSL for creating a to-do list application with categories, priorities, and due dates.

Additional Resources



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)