A Domain Specific Language (DSL) in Kotlin

What is a DSL?

A DSL, or Domain Specific Language, is a powerful tool for software developers. They allow us to create a language specific to a certain domain, making it easier to write code that is more concise, readable and understandable when compared to traditional programming languages. This is because they are highly focused on a smaller problem area instead of being general-purpose. In this short series of blog posts, I will focus on internal DSLs and how I built one using Kotlin.

DSLs can be internal or external, with internal DSLs embedded within a host language and external DSLs acting as standalone languages. 

Since a DSL is tailored to a specific domain or problem, it allows developers to write more expressive and concise code. In turn, that makes it easier to understand and maintain. 

DSLs can be used in a variety of domains, such as configuration files, build scripts and testing frameworks. They can also be used to create more readable ways to define complex data structures or algorithms. In this small project, I will be making a DSL to test OpenAPI services.

Advantages of DSLs

The key benefits of using DSLs in a project include: 

  • Readability: DSLs can make code more readable and easier to understand, especially for non-technical stakeholders.
  • Expressiveness: DSLs allow you to write more expressive and concise code, reducing the amount of boilerplate code needed.
  • Domain-specific: DSLs are tailored to a specific domain or problem, making writing code for that domain easier.
  • Type safety: DSLs can provide type safety, ensuring that code is correct at compile time. For example, using a DSL to perform configuration instead of a text file.

Building a DSL with Kotlin

In the past, I have created a DSL using Groovy, but not with Kotlin, so I read through Baeldung’s tutorial and the Kotlin documentation on type-safe builders to help me get started. These articles explain the two language features essential for building a DSL in Kotlin: extension functions and lambdas with receivers

Also, while not strictly necessary, I’ll use infix function notation to help with the readability of the DSL.

Extension functions

Extension functions allow us to add new functions to existing classes. For example, if I wanted to add a print function to the String class that would print out the string to the console, I could write an extension function like:

fun String.print() {
    println(this)
}

This function can then be called on any String object like:

"Hello, world!".print()

It is important to note that this does not change the String class everywhere in the code, only where the extension function is used. You'll also have to explicitly import the extension function if it's not currently in scope.

Lambdas with receivers

Lambdas with receivers (also referred to as functions with receivers) are a bit more complicated. They allow us to access the members of an object directly within a lambda body without explicitly referencing the object with an alias.

For example, let's say I have a Person data class with the properties name, age and email along with a method to print out each of these properties:

data class Person (val name: String, val age: Int, val email: String) {
    fun printName() = println(name)
    fun printAge() = println(age)
    fun printEmail() = println(email)
}

If I then define a function inside the Person class that takes a simple lambda as an argument to perform some actions on a Person instance:

fun doActions(action: (Person) -> Unit) {
    action(this)
}

I would then reference the object with a name within the lambda:

val alice = Person("Alice", 25, "[email protected]")

alice.doActions { person ->
    person.printName()
    person.printAge()
    person.printEmail()
}

Or I could use the implicit name of the single-parameter lambda, “it”:

val alice = Person("Alice", 25, "[email protected]")

alice.doActions {
    it.printName()
    it.printAge()
    it.printEmail()
}

However, if I define a lambda with a receiver:

fun doActions(action: Person.() -> Unit) {
    action(this)
}

I can access the members of the Person object directly within the lambda body without referencing the object with a name. The result is a much nicer DSL:

val alice = Person("Alice", 25, "[email protected]")

alice.doActions {
    printName()
    printAge()
    printEmail()
}

Infix notation

When declaring a function in Kotlin, it’s possible to mark it with the infix keyword as long as it meets certain criteria. It must:

  • Be a member function or extension function
  • Have only a single parameter which also does not have a default value
  • Not accept a variable number of arguments

This allows a function to be used without needing parentheses around the parameter or the dot operator on the receiver. For example, the built-in infix function xor on the Int class can be called like any normal function:

val a = 12345.xor(67890)

Or, it can be expressed like this using infix notation:

val a = 12345 xor 67890

By combining these three powerful Kotlin language features, I can create a type-safe DSL-like syntax without writing a parser, lexer, or compiler as I would if I were creating an external DSL.

In part two of this blog post, I’ll explain the steps to combine these features and build a DSL for testing OpenAPI services. Check it out here!

Wayne Bagguley
Wayne Bagguley

Senior Software Engineer

Wayne has been with Matillion for over four years and has recently been designing and implementing Spring-Boot microservices for the Data Productivity Cloud.

Get started today

Matillion's comprehensive data pipeline platform offers more than point solutions.