Crafting Type‑Safe DSLs in Kotlin with Inline Functions and Reified Type Parameters
Why a Type‑Safe Builder Matters
When I work on an Android or server project, I often need to construct complex request objects or configuration blocks. Hand‑writing constructors or nested maps quickly becomes verbose and prone to mistakes. A fluent builder can improve readability, reduce boilerplate, and even give you compile‑time safety because the DSL knows exactly which properties belong to which type.
One pattern I rely on daily is using Kotlin's inline functions together with reified type parameters. This combination lets you define a generic builder that works for any data class while still providing a clean, chainable API. The result is a DSL that feels natural, is easy to maintain, and catches errors early.
Building the Core Infrastructure
Before we dive into the usage, let’s examine the minimal building blocks that make this technique possible.
Define the Data Model
Suppose we need to send a user profile to an API. The profile consists of a name, an email, and an optional age. In Kotlin, we can express this as a simple data class:
data class UserProfile(
val name: String,
val email: String,
val age: Int?
)
The class is immutable, which is great for reasoning about data flow. However, constructing instances manually requires remembering the order of parameters:
val profile = UserProfile("Bob", "bob@example.com", null)
That is where a builder shines.
Implement the Generic Builder
We create a generic builder class that holds the same fields as the target type. Because the builder mirrors the data class, we can reuse it for any similar structure. The key is an inline function that takes a reified type parameter, guaranteeing that the builder returns the exact class we expect.
Here is a compact, production‑ready implementation:
class ProfileBuilder {
var name: String = ""
var email: String = ""
var age: Int? = null
fun name(value: String) = apply { this.name = value }
fun email(value: String) = apply { this.email = value }
fun age(value: Int) = apply { this.age = value }
fun build(): UserProfile = UserProfile(name, email, age)
}
Notice each setter returns `apply`. This pattern lets us chain calls without creating intermediate variables. The `apply` block receives `this`, so we can mutate the builder and then return it.
Now we wrap the builder in an inline function that is generic over the target type `T`. The reified parameter allows us to call the function with a type argument without boxing, and it also enables the compiler to check that the builder actually produces the expected class.
inline fun <reified T> buildProfile(crossinline block: ProfileBuilder.() -> Unit): T {
val builder = ProfileBuilder()
block(builder)
// Safe cast because ProfileBuilder is known to produce UserProfile
return builder.build() as T
}
Because `ProfileBuilder` is specific to `UserProfile`, we could also make the builder generic, but for most real‑world scenarios a dedicated builder per domain is clearer and avoids runtime reflection.
Important: The `as T` cast is safe only when you are certain the builder returns the exact type you requested. If you ever change the builder to produce a different class, the compiler will warn you because the return type no longer matches.
Putting It All Together – A Real‑World Example
Imagine a networking layer that needs to send a `UserProfile` to a REST endpoint. The caller should be able to construct the payload in a way that reads like a sentence:
val requestBody = buildProfile {
name("Alice")
email("alice@example.com")
age(28)
}
// requestBody is now a UserProfile ready for serialization
val json = gson.toJson(requestBody)
The DSL improves readability because each line describes *what* is being set, not *how*. If a required field is omitted, the compiler will not complain because the fields are nullable in the data class. However, you can enforce non‑nullability by moving the `var` to `val` inside the builder and providing a default that throws an exception in `build()`. Here’s an enhanced version that validates required fields:
class ValidatingProfileBuilder {
private var name: String = ""
private var email: String = ""
private var age: Int? = null
fun name(value: String) = apply { this.name = value }
fun email(value: String) = apply { this.email = value }
fun age(value: Int) = apply { this.age = value }
fun build(): UserProfile {
if (name.isBlank()) throw IllegalStateException("Name is required")
if (email.isBlank()) throw IllegalStateException("Email is required")
return UserProfile(name, email, age)
}
}
Now the same `buildProfile` function can be reused, and the caller gets immediate feedback if they forget a mandatory field at compile time (by making the fields `val` and not providing setters). This pattern is especially handy when dealing with configuration objects that have many optional parameters but a few that must be present.
Best Practices and Gotchas
- Keep the builder simple. If you start adding complex logic inside each setter, you may lose the clarity you gained.
- Prefer inline functions for the DSL entry point because they reduce lambda overhead and improve stack traces.
- Use reified type parameters only when you need compile‑time safety. Overusing them can make the API harder to understand.
- Consider making builder properties
privateand expose only the fluent methods. This prevents accidental misuse from outside the block. - When you need to support multiple data classes with the same shape, extract a common base builder and use generic type parameters to avoid duplication.
When to Reach for a Simpler Approach
Not every situation warrants a builder. For a single‑line object or when the constructor already provides named arguments, direct instantiation is clearer. Also, if you are building a very small configuration (say, three fields) and performance is critical, the overhead of a builder may outweigh its benefits.
In those cases, you can fall back to a simple data class and rely on Kotlin’s primary constructor with default values:
data class MinimalProfile(
val name: String,
val email: String,
val age: Int? = null
)
The decision is a trade‑off between readability and complexity. The builder pattern shines when the construction logic is repetitive or when you need validation, defaults, or conditional fields.
Conclusion
Inline functions paired with reified type parameters give you a powerful, type‑safe way to write DSLs in Kotlin. By encapsulating the builder inside a generic entry point, you can reuse the same construction logic across multiple domain objects while keeping the API fluent and self‑documenting. The technique is lightweight, performs well, and integrates cleanly with existing codebases. Next time you find yourself juggling multiple constructors or nested maps, consider reaching for a builder—your future self will thank you.