Skip to main content

Kotlin Type-safe Builders

Type-safe builders are one of Kotlin's most elegant features, allowing you to create domain-specific languages (DSLs) that are both readable and type-safe. If you've ever worked with HTML builders, UI frameworks, or test frameworks in Kotlin, you've likely encountered this pattern already.

What are Type-safe Builders?

Type-safe builders are a design pattern in Kotlin that lets you create complex objects through a sequence of nested method calls with an easy-to-read, declarative syntax. The "type-safe" part means the compiler checks your builder code, catching errors at compile time rather than runtime.

The magic behind this pattern comes from several Kotlin features working together:

  • Lambda expressions with receivers
  • Extension functions
  • Function literals with receiver
  • Operator overloading

Basic Concepts

Lambda with Receiver

The foundation of type-safe builders is the concept of lambda with receiver, which is a function that can be called with a specific context (receiver) inside its body.

kotlin
fun buildString(action: StringBuilder.() -> Unit): String {
val sb = StringBuilder()
sb.action()
return sb.toString()
}

val result = buildString {
append("Hello, ")
append("World!")
}
println(result) // Output: Hello, World!

In this example, StringBuilder.() -> Unit is a function type with a receiver - it's a function that can be called on a StringBuilder instance. Inside the lambda, this refers to the StringBuilder instance, allowing direct access to its methods like append().

Building a Simple DSL

Let's create a simple DSL for building an HTML-like structure:

kotlin
class TagBuilder(val name: String) {
val children = mutableListOf<Any>()
val attributes = mutableMapOf<String, String>()

fun attribute(name: String, value: String) {
attributes[name] = value
}

fun tag(name: String, init: TagBuilder.() -> Unit): TagBuilder {
val tag = TagBuilder(name).apply(init)
children.add(tag)
return tag
}

fun text(value: String) {
children.add(value)
}

override fun toString(): String {
val attributesString = if (attributes.isEmpty()) ""
else attributes.entries.joinToString(" ", prefix = " ") { "${it.key}=\"${it.value}\"" }

return if (children.isEmpty()) {
"<$name$attributesString/>"
} else {
val childrenString = children.joinToString("")
"<$name$attributesString>$childrenString</$name>"
}
}
}

fun html(init: TagBuilder.() -> Unit): TagBuilder {
return TagBuilder("html").apply(init)
}

Now we can build HTML structures with a clean, readable syntax:

kotlin
val htmlContent = html {
tag("head") {
tag("title") {
text("My Page")
}
}
tag("body") {
tag("h1") {
text("Welcome!")
}
tag("p") {
attribute("class", "content")
text("This is a paragraph.")
}
}
}

println(htmlContent)

Output:

<html><head><title>My Page</title></head><body><h1>Welcome!</h1><p class="content">This is a paragraph.</p></body></html>

Adding Syntactic Sugar

We can make our DSL even more expressive using operator overloading and property delegates:

kotlin
class TagBuilder(val name: String) {
val children = mutableListOf<Any>()
val attributes = mutableMapOf<String, String>()

// Operator function to allow attribute assignment with []
operator fun set(attributeName: String, value: String) {
attributes[attributeName] = value
}

// Unary + operator to add text content
operator fun String.unaryPlus() {
children.add(this)
}

fun tag(name: String, init: TagBuilder.() -> Unit): TagBuilder {
val tag = TagBuilder(name).apply(init)
children.add(tag)
return tag
}

// ... toString() and other methods as before
}

With these additions, our HTML building becomes even cleaner:

kotlin
val htmlContent = html {
tag("body") {
tag("div") {
this["id"] = "container"
this["class"] = "wrapper"

tag("p") {
+"This text is added using the unary plus operator"
}
}
}
}

Real-World Example: Building a UI Layout

Let's create a simple UI layout DSL, similar to what you might find in frameworks like Jetpack Compose:

kotlin
// UI component base class
open class Component(val id: String) {
val children = mutableListOf<Component>()
val properties = mutableMapOf<String, Any>()

operator fun set(name: String, value: Any) {
properties[name] = value
}

fun <T : Component> add(component: T, init: T.() -> Unit = {}): T {
component.apply(init)
children.add(component)
return component
}

override fun toString(): String {
val props = properties.entries.joinToString(", ") { "${it.key}: ${it.value}" }
val childrenStr = if (children.isEmpty()) ""
else children.joinToString("\n ", prefix = "\n ", postfix = "\n")
return "${javaClass.simpleName}(id='$id', $props)$childrenStr"
}
}

// UI components
class Container(id: String) : Component(id)
class Button(id: String) : Component(id)
class TextField(id: String) : Component(id)
class Label(id: String) : Component(id)

// Entry point function
fun ui(init: Container.() -> Unit): Container {
return Container("root").apply(init)
}

// Usage
val layout = ui {
this["width"] = 800
this["height"] = 600

add(Label("welcomeLabel")) {
this["text"] = "Welcome to My App!"
this["fontSize"] = 24
}

add(TextField("nameInput")) {
this["hint"] = "Enter your name"
this["width"] = 200
}

add(Button("submitBtn")) {
this["text"] = "Submit"
this["onClick"] = "handleSubmit()"
}
}

println(layout)

Output:

Container(id='root', width: 800, height: 600)
Label(id='welcomeLabel', text: Welcome to My App!, fontSize: 24)
TextField(id='nameInput', hint: Enter your name, width: 200)
Button(id='submitBtn', text: Submit, onClick: handleSubmit())

Type Safety Benefits

The true power of Kotlin builders is type safety. The compiler ensures:

  1. Only valid methods can be called within each context
  2. Properties accept only values of the correct type
  3. Required parameters are provided

For example, if we tried to add a property that doesn't exist:

kotlin
val layout = ui {
this["invalidProperty"] = 123 // This is allowed, but might not be ideal

add(Button("btn")) {
doSomethingThatDoesntExist() // Compilation error!
}
}

To improve type safety further, we could define specific properties for each component type rather than using the generic property map approach.

Best Practices for Creating Type-safe Builders

  1. Define clear contexts: Make sure each builder has a well-defined scope and purpose
  2. Use meaningful names: Choose function and parameter names that make the DSL readable
  3. Provide sensible defaults: Reduce boilerplate by having reasonable default values
  4. Add documentation: DSLs can be confusing at first; good docs help adoption
  5. Consider using @DslMarker: This annotation prevents accidental access to outer scope receivers

Example with @DslMarker

kotlin
@DslMarker
annotation class UiDsl

@UiDsl
open class Component(val id: String) {
// Implementation as before
}

With @DslMarker, Kotlin will prevent implicit access to outer receivers, making builders safer.

Summary

Kotlin type-safe builders are a powerful feature that allows you to create expressive, readable DSLs while maintaining strong type safety. By leveraging lambdas with receivers, extension functions, and other Kotlin features, you can create intuitive APIs that feel like they're part of the language.

Type-safe builders are used extensively in Kotlin libraries and frameworks, including:

  • Ktor for building HTTP clients and servers
  • Jetpack Compose for Android UI
  • KotlinTest/Kotest for writing expressive tests
  • Gradle Kotlin DSL for build scripts

By mastering type-safe builders, you unlock a powerful tool for creating clean, readable, and maintainable code in your Kotlin projects.

Additional Resources

Exercises

  1. Extend the HTML builder to support more HTML elements and attributes
  2. Create a DSL for building JSON objects
  3. Build a type-safe builder for SQL queries
  4. Create a DSL for configuring a mock HTTP server for testing
  5. Improve the UI DSL by adding specific properties for each component type instead of using the generic property map


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