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.
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:
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:
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:
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:
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:
// 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:
- Only valid methods can be called within each context
- Properties accept only values of the correct type
- Required parameters are provided
For example, if we tried to add a property that doesn't exist:
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
- Define clear contexts: Make sure each builder has a well-defined scope and purpose
- Use meaningful names: Choose function and parameter names that make the DSL readable
- Provide sensible defaults: Reduce boilerplate by having reasonable default values
- Add documentation: DSLs can be confusing at first; good docs help adoption
- Consider using @DslMarker: This annotation prevents accidental access to outer scope receivers
Example with @DslMarker
@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
- Kotlin official documentation on type-safe builders
- Kotlin DSL guide
- Anko library (a good example of builders for Android UI)
Exercises
- Extend the HTML builder to support more HTML elements and attributes
- Create a DSL for building JSON objects
- Build a type-safe builder for SQL queries
- Create a DSL for configuring a mock HTTP server for testing
- 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! :)