What Is a DSL?
A DSL (Domain-Specific Language) is a small language designed to solve problems in a specific domain. Kotlin allows you to naturally create type-safe DSLs through Lambda with Receiver. Gradle build scripts, Ktor routing, and Jetpack Compose are prime examples of Kotlin DSLs.
This article walks through the mechanics of lambda with receiver up to practical DSL implementation, step by step.
Lambda with Receiver
A lambda with receiver allows you to directly call methods and properties of a specific object inside the lambda without using this. The standard library functions apply, with, and buildString all use this pattern.
// Basic principle of lambda with receiver
fun buildGreeting(block: StringBuilder.() -> Unit): String {
val sb = StringBuilder()
sb.block() // StringBuilder is the receiver
return sb.toString()
}
// HTML tag builder — Using lambda with receiver
class TagBuilder(private val name: String) {
private val children = mutableListOf<String>()
private val attributes = mutableMapOf<String, String>()
// Set attribute
fun attr(key: String, value: String) {
attributes[key] = value
}
// Add text
fun text(content: String) {
children.add(content)
}
// Add child tag
fun tag(name: String, block: TagBuilder.() -> Unit) {
val child = TagBuilder(name)
child.block() // Invoke lambda with receiver
children.add(child.build())
}
fun build(): String {
val attrStr = if (attributes.isEmpty()) ""
else " " + attributes.entries.joinToString(" ") { "${it.key}=\"${it.value}\"" }
val content = children.joinToString("\n")
return "<$name$attrStr>\n$content\n</$name>"
}
}
fun html(block: TagBuilder.() -> Unit): String {
val builder = TagBuilder("html")
builder.block()
return builder.build()
}
fun main() {
// Using buildGreeting
val greeting = buildGreeting {
append("Hello, ") // Call StringBuilder's method directly
append("Kotlin ")
append("DSL!")
}
println(greeting)
// Output: Hello, Kotlin DSL!
// Using the HTML DSL
val page = html {
tag("head") {
tag("title") {
text("Kotlin DSL Example")
}
}
tag("body") {
tag("h1") {
text("Welcome")
}
tag("p") {
attr("class", "content")
text("This HTML was built with Kotlin DSL.")
}
}
}
println(page)
// Output:
// <html>
// <head>
// <title>
// Kotlin DSL Example
// </title>
// </head>
// <body>
// <h1>
// Welcome
// </h1>
// <p class="content">
// This HTML was built with Kotlin DSL.
// </p>
// </body>
// </html>
}
Restricting Scope with @DslMarker
In nested DSLs, accessing methods from an outer scope can cause unintended behavior. The @DslMarker annotation restricts scope to prevent this issue.
// DslMarker definition
@DslMarker
annotation class ConfigDsl
// Configuration DSL implementation
@ConfigDsl
class ServerConfig {
var host: String = "localhost"
var port: Int = 8080
private var _database: DatabaseConfig? = null
private var _cache: CacheConfig? = null
fun database(block: DatabaseConfig.() -> Unit) {
_database = DatabaseConfig().apply(block)
}
fun cache(block: CacheConfig.() -> Unit) {
_cache = CacheConfig().apply(block)
}
override fun toString(): String {
return """
|Server Config:
| Host: $host:$port
| DB: $_database
| Cache: $_cache
""".trimMargin()
}
}
@ConfigDsl
class DatabaseConfig {
var url: String = ""
var username: String = ""
var password: String = ""
var poolSize: Int = 10
override fun toString() = "$url (pool: $poolSize)"
}
@ConfigDsl
class CacheConfig {
var type: String = "redis"
var host: String = "localhost"
var ttlSeconds: Int = 3600
override fun toString() = "$type://$host (TTL: ${ttlSeconds}s)"
}
fun server(block: ServerConfig.() -> Unit): ServerConfig {
return ServerConfig().apply(block)
}
fun main() {
val config = server {
host = "api.example.com"
port = 443
database {
url = "jdbc:postgresql://db.example.com:5432/myapp"
username = "app_user"
password = "secret"
poolSize = 20
// host = "..." // Thanks to @DslMarker, ServerConfig.host is not accessible here
}
cache {
type = "redis"
host = "cache.example.com"
ttlSeconds = 7200
}
}
println(config)
// Output:
// Server Config:
// Host: api.example.com:443
// DB: jdbc:postgresql://db.example.com:5432/myapp (pool: 20)
// Cache: redis://cache.example.com (TTL: 7200s)
}
Without @DslMarker, you could access ServerConfig.host inside the database { } block, causing confusion. With @DslMarker, only the current scope’s receiver methods are directly accessible.
Practical DSL — Validation Rule Builder
Here is a useful input validation DSL for real-world use.
@DslMarker
annotation class ValidationDsl
@ValidationDsl
class ValidationBuilder<T> {
private val rules = mutableListOf<Pair<String, (T) -> Boolean>>()
// Add a rule
fun rule(description: String, predicate: (T) -> Boolean) {
rules.add(description to predicate)
}
// Run validation
fun validate(value: T): ValidationResult {
val failures = rules
.filter { (_, predicate) -> !predicate(value) }
.map { (desc, _) -> desc }
return if (failures.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(failures)
}
}
}
sealed class ValidationResult {
data object Valid : ValidationResult()
data class Invalid(val errors: List<String>) : ValidationResult()
}
fun <T> validate(value: T, block: ValidationBuilder<T>.() -> Unit): ValidationResult {
val builder = ValidationBuilder<T>()
builder.block()
return builder.validate(value)
}
// Usage example
data class SignUpForm(
val email: String,
val password: String,
val age: Int
)
fun main() {
val form = SignUpForm(
email = "user@example.com",
password = "abc",
age = 15
)
val result = validate(form) {
rule("Email must contain @") { it.email.contains("@") }
rule("Password must be at least 8 characters") { it.password.length >= 8 }
rule("Must be at least 18 years old") { it.age >= 18 }
}
when (result) {
is ValidationResult.Valid -> println("Validation passed!")
is ValidationResult.Invalid -> {
println("Validation failed:")
result.errors.forEach { println(" - $it") }
}
}
// Output:
// Validation failed:
// - Password must be at least 8 characters
// - Must be at least 18 years old
// Validating a valid form
val validForm = form.copy(password = "securePassword123", age = 25)
val validResult = validate(validForm) {
rule("Email must contain @") { it.email.contains("@") }
rule("Password must be at least 8 characters") { it.password.length >= 8 }
rule("Must be at least 18 years old") { it.age >= 18 }
}
println(if (validResult is ValidationResult.Valid) "Valid form!" else "Invalid")
// Output: Valid form!
}
Summary
- Lambda with receiver: The core DSL syntax that allows direct access to receiver object members inside a lambda.
- @DslMarker: Restricts outer scope access in nested DSLs to prevent mistakes.
- apply/with/run: Standard library scope functions also use lambda with receiver.
- Replaces the Builder pattern: Replacing Java’s Builder pattern with DSL greatly improves readability.
- Type safety: Validity is verified at compile time, making it safer than string-based configuration.