· 7 min read

Lessons Learned from Kotlin: Kotlin DSL and Annotations

This article was auto-translated from Chinese. Some nuances may be lost in translation.

Recently, I developed a small daily task management tool using Kotlin. The primary goal was to make it easier for backend colleagues to maintain it together, while also gaining new sparks and perspectives from learning a new language—plus seizing the opportunity to learn from the many Java and JVM pros at our company.

The features of this daily task tool are quite simple:

  • CRUD operations for TODOs; each Todo is scheduled upon creation, and if not completed within the allotted time, a Slack notification is triggered.
  • Set up recurring schedules, such as checking Pull Requests or Jira progress daily.

Writing in Kotlin is very pleasant. It preserves the safety of static typing and compiler benefits on one hand, while reducing the verbosity typical of Java on the other. Moreover, Kotlin offers plenty of handy syntax features that help simplify code. In this article, I’ll introduce some of the concepts I learned while picking up Kotlin.

Kotlin DSL

The code below is valid Kotlin syntax:

Bot.sendMessage(channelId) {
   section {
       plainText("This is a plain text section block.")
   } 
   section {
       markdownText("This is a mrkdwn section block :ghost: *this is bold*, and ~this is crossed out~, and <https://google.com|this is a link>")
   }
   divider()
}  

After executing the code, it converts to something like this:

{
	"blocks": [
		{
			"type": "section",
			"text": {
				"type": "plain_text",
				"text": "This is a plain text section block.",
				"emoji": true
			}
		},
		{
			"type": "section",
			"text": {
				"type": "mrkdwn",
				"text": "This is a mrkdwn section block :ghost: *this is bold*, and ~this is crossed out~, and <https://google.com|this is a link>"
			}
		}
	]
}

You can compose messages by defining the format specified in Slack’s Block Kit.

Suppose you want to send a message to Slack today; you can use Block Kit to construct messages in Slack. According to the official API guide, you can write it like this:

val slack = Slack.getInstance()
val token = System.getenv("token")
val response = slack.methods(token).chatPostMessage { req -> req
  .channel("C1234567")
  .blocks {
    section {
      // "text" fields can be constructed via `plainText()` and `markdownText()`
      markdownText("*Please select a restaurant:*")
    }
    divider()
    actions {
      button {
          text("Farmhouse", emoji = true)
          value("v1")
      }
      button {
          text("Kin Khao", emoji = true)
          value("v2")
      }
    }
  }
}

It looks clean and intuitive, almost like writing some kind of Component, which immediately made me fall in love with Kotlin’s syntax.

Additionally, in the exposed library, you can write SQL queries like this:

val status = "DONE"
Todos.select {
    Todos.status eq (status)
}

This also leverages Kotlin DSL capabilities to create the feeling of “writing native SQL.”

So how is Kotlin DSL achieved under the hood? It mainly combines a few concepts:

  • extension functions
  • infix notation
  • lambda expressions
  • function literals with receiver

Extensions

In Kotlin, you can add a new method to a class without using inheritance or decorator patterns, as shown in the syntax below:

fun Int.add2() {
  this + 2
}

This way, you can call it directly on the Int type:

fun add() {
  val a = 100
  a.add2()
}

An extension’s implementation doesn’t literally cram the method into the class; instead, it allows you to invoke the extension implementation as if it were a member function using dot notation. So why not just implement it inside the class directly? Sometimes you can’t easily modify the code, such as with third-party libraries, making extensions an extremely handy tool.

Infix notation

In Kotlin, if you mark a function with infix, it means the function can be called omitting the dot and parentheses. To achieve this, infix notation must meet a few requirements:

  • It must be a member function or an extension function.
  • It must have a single parameter.
  • The parameter must not accept a default value.

The result looks like this:

infix fun Int.addSome(num: Int) {
  this + num
}

fun add() {
  val a = 100
  a.addSome(10)
  a addSome 10
}

Here, a.addSome(10) and a addSome 10 are completely identical. Of course, 10 addSome a would also be a valid expression.

The most common infix function in Kotlin is probably to. In Kotlin, you can declare a map like this:

val map = mapOf("name" to "kalan", "age" to 25)

to is actually an infix function—syntactic sugar for conveniently creating a Pair. Its implementation looks like this:

public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)

Through this definition, we can reduce visual noise in the code as much as possible, significantly improving readability.

Lambda Expressions

Lambda expressions in Kotlin are quite flexible. You can write:

fun test(a: Int, block: () -> Unit) {

}

test(1, {
  println("hello")
})

When a lambda expression is the last parameter, you can move the lambda outside the parentheses, like this:

fun test(a: Int, block: () -> Unit) {

}

test(1) {
  println("hello")
}

Both are completely equivalent. Furthermore, Kotlin performs type inference on your lambda function, so if you write it like this:

fun test(a: Int, block: (i: Int) -> Unit) {

}

test(1) {
  println("hello")
}

In the IDE, it will be displayed as the value parameter, where it is the i passed into the block.

Screenshot from 2020-10-25 19-29-58

This is equivalent to:

fun test(a: Int, block: (i: Int) -> Unit) {

}

test(1) { i ->
  println("hello", i)
}

For this part, you can refer to Scope Functions in Kotlin to understand how they work.

Function Literals with Receiver

Let’s introduce this directly using an example from the official documentation. In Kotlin, a function can be written like this:

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

Let’s look at the init parameter first. It’s declared as a function typed as HTML.() -> Unit, known as a function type with receiver. This means that at some point, we need to pass html as the context to the init function—somewhat like apply(context) in JavaScript, but with the added benefit of being type-safe. T.() -> K means executing a member function on type T that returns K.

Next, let’s look at another example. In kotlinx.serialization, we can declare a Json instance like this:

val serializer = Json {
  ignoreUnknownKeys = true
  encodeDefaults = true
  coerceInputValues = true
}

Under the hood, the implementation looks like this:

public fun Json(from: Json = Json.Default, builderAction: JsonBuilder.() -> Unit): Json {
    val builder = JsonBuilder(from.configuration)
    builder.builderAction()
    val conf = builder.build()
    return JsonImpl(conf)
}

In other words, in the code above, the actual execution looks like this:

val builder = JsonBuilder()
builder.ignoreUnkownKeys = true
builder.encodeDefaults = true
builder.coerceInputValues = true
builder.build()

By leveraging these Kotlin syntax features combined with a bit of imagination, you can build simple yet powerful DSLs to streamline your code—and everything remains type-safe!

Annotations

When implementing the Cronjob feature, I created a cronjob annotation for better readability, inspired by Spring Boot’s @Scheduled:

@CronSchedule("00 18 * * 1-5")
class NotifyMeCheck: Scheduler(), Workable {
    override fun work(args: Arg) {
		// job implementation
    }
}

@Target(AnnotationTarget.CLASS)
@MustBeDocumented
annotation class CronSchedule(val cronExpression: String) {}

Then at runtime, it scans all nested classes in CronWorkEntry for those marked with CronSchedule and schedules the cron jobs, roughly like this:

CronWorkEntry::class.nestedClasses.forEach {
    val annotation = it.findAnnotation<CronSchedule>()
    if (annotation != null) {
      val worker = it.createInstance() as Scheduler
      worker.performCron(annotation.cronExpression,"${CronWorkEntry::class.qualifiedName!!}$${it.simpleName!!}", "", true)
    }
}

However, it.createInstance() as Scheduler feels a bit off here. Because there is no compile-time constraint enforcing that the class must be a Scheduler, failing to inherit from it would throw an Exception directly at runtime. I wonder if there is a way to restrict an annotation so that it can only be applied to certain class types.

Related Posts

Explore Other Topics