
I used to hate building complex objects in my Android code. You know the drill — you create an object, set a bunch of properties, forget to set one important field, and boom! Your app crashes in production.
Last month, I spent three hours debugging why our payment system was failing. Turns out I forgot to set the currency field when building a payment request. The object looked complete, but it wasn’t. That’s when I decided to finally learn about Kotlin’s type-safe builders.
What Are Type-safe Builders Anyway?
Think of a type-safe builder like a checklist that the compiler checks for you. Instead of creating objects and hoping you didn’t miss anything, the builder pattern forces you to provide all the required information before you can build the object.
Here’s a simple example. Let’s say we’re building a User object:
// The old way (error-prone)
val user = User(
name = "John",
email = "john@example.com"
// Oops, forgot the age!
)
// The builder way (type-safe)
val user = user {
name = "John"
email = "john@example.com"
age = 25
} // This won't compile if we miss anything!
Building Our First Type-safe Builder
Let me show you how I built a simple builder for our email system. We send a lot of emails, and I was tired of missing required fields.
data class Email(
val to: String,
val subject: String,
val body: String,
val from: String? = null
)
class EmailBuilder {
var to: String? = null
var subject: String? = null
var body: String? = null
var from: String? = null
fun build(): Email {
return Email(
to = to ?: throw IllegalStateException("Email 'to' is required"),
subject = subject ?: throw IllegalStateException("Email 'subject' is required"),
body = body ?: throw IllegalStateException("Email 'body' is required"),
from = from
)
}
}
fun email(block: EmailBuilder.() -> Unit): Email {
return EmailBuilder().apply(block).build()
}
Now I can build emails like this:
val welcomeEmail = email {
to = "newuser@example.com"
subject = "Welcome to our app!"
body = "Thanks for joining us!"
from = "noreply@ourapp.com"
}
This is better, but it still has a problem. The compiler doesn’t stop me from calling the builder before setting all required fields. Let’s fix that.
Making It Actually Type-safe
Here’s where Kotlin’s DSL magic happens. We can use sealed interfaces to track which fields have been set:
sealed interface EmailBuilderState
object EmptyEmailBuilder : EmailBuilderState
sealed interface WithTo : EmailBuilderState
sealed interface WithSubject : EmailBuilderState
sealed interface WithBody : EmailBuilderState
class TypeSafeEmailBuilder<T : EmailBuilderState> {
internal var to: String? = null
internal var subject: String? = null
internal var body: String? = null
internal var from: String? = null
fun to(email: String): TypeSafeEmailBuilder<WithTo> {
this.to = email
return this as TypeSafeEmailBuilder<WithTo>
}
fun subject(text: String): TypeSafeEmailBuilder<T> where T : WithTo {
this.subject = text
return this as TypeSafeEmailBuilder<WithSubject>
}
fun body(text: String): TypeSafeEmailBuilder<T> where T : WithTo, T : WithSubject {
this.body = text
return this as TypeSafeEmailBuilder<WithBody>
}
fun from(email: String): TypeSafeEmailBuilder<T> {
this.from = email
return this
}
}
fun TypeSafeEmailBuilder<WithBody>.build(): Email {
return Email(to!!, subject!!, body!!, from)
}
fun email(block: TypeSafeEmailBuilder<EmptyEmailBuilder>.() -> TypeSafeEmailBuilder<WithBody>): Email {
return TypeSafeEmailBuilder<EmptyEmailBuilder>().block().build()
}
Now Kotlin won’t let me build an incomplete email:
// This won't compile!
val incomplete = email {
to("user@example.com")
// Missing subject and body
}
// This works
val complete = email {
to("user@example.com")
.subject("Hello")
.body("World")
}
A Simpler Approach with @DslMarker
Actually, let me show you a much simpler way that I use in real projects. Kotlin’s @DslMarker makes building DSLs super easy:
@DslMarker
annotation class EmailDsl
data class Email(
val to: String,
val subject: String,
val body: String,
val from: String? = null,
val cc: List<String> = emptyList(),
val bcc: List<String> = emptyList()
)
@EmailDsl
class EmailBuilder {
lateinit var to: String
lateinit var subject: String
lateinit var body: String
var from: String? = null
private val ccList = mutableListOf<String>()
private val bccList = mutableListOf<String>()
fun cc(vararg emails: String) {
ccList.addAll(emails)
}
fun bcc(vararg emails: String) {
bccList.addAll(emails)
}
internal fun build(): Email {
check(::to.isInitialized) { "Email 'to' must be set" }
check(::subject.isInitialized) { "Email 'subject' must be set" }
check(::body.isInitialized) { "Email 'body' must be set" }
return Email(to, subject, body, from, ccList, bccList)
}
}
fun email(block: EmailBuilder.() -> Unit): Email {
return EmailBuilder().apply(block).build()
}
Usage is clean and readable:
val email = email {
to = "user@example.com"
subject = "Welcome!"
body = "Thanks for joining us."
from = "noreply@app.com"
cc("manager@app.com", "support@app.com")
bcc("analytics@app.com")
}
Real-world Example: Building HTTP Requests
At my job, we use builders for creating HTTP requests. Here’s how I built one with Retrofit:
@DslMarker
annotation class HttpDsl
data class ApiRequest(
val url: String,
val method: String,
val headers: Map<String, String>,
val queryParams: Map<String, String>,
val body: String?
)
@HttpDsl
class RequestBuilder {
lateinit var url: String
var method: String = "GET"
private val headerMap = mutableMapOf<String, String>()
private val queryMap = mutableMapOf<String, String>()
var body: String? = null
@HttpDsl
class HeadersBuilder {
internal val headers = mutableMapOf<String, String>()
operator fun String.invoke(value: String) {
headers[this] = value
}
}
@HttpDsl
class QueryBuilder {
internal val params = mutableMapOf<String, String>()
operator fun String.invoke(value: String) {
params[this] = value
}
}
fun headers(block: HeadersBuilder.() -> Unit) {
val builder = HeadersBuilder().apply(block)
headerMap.putAll(builder.headers)
}
fun query(block: QueryBuilder.() -> Unit) {
val builder = QueryBuilder().apply(block)
queryMap.putAll(builder.params)
}
fun post(bodyContent: String) {
method = "POST"
body = bodyContent
}
internal fun build(): ApiRequest {
check(::url.isInitialized) { "URL must be set" }
return ApiRequest(url, method, headerMap, queryMap, body)
}
}
fun request(block: RequestBuilder.() -> Unit): ApiRequest {
return RequestBuilder().apply(block).build()
}
Now I can build requests like this:
val apiRequest = request {
url = "https://api.example.com/users"
headers {
"Authorization"("Bearer $token")
"Content-Type"("application/json")
}
query {
"page"("1")
"limit"("20")
}
post("""{
"name": "John Doe",
"email": "john@example.com"
}""")
}
Building HTML with Type-safe DSL
One of my favorite Kotlin features is building HTML with kotlinx.html. Here’s how you can create your own simple HTML DSL:
@DslMarker
annotation class HtmlDsl
abstract class Element(private val name: String) {
private val children = mutableListOf<Element>()
private val attributes = mutableMapOf<String, String>()
protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
tag.init()
children.add(tag)
return tag
}
fun attribute(name: String, value: String) {
attributes[name] = value
}
override fun toString(): String {
val attrs = attributes.entries.joinToString(" ") { "${it.key}=\"${it.value}\"" }
val attrsStr = if (attrs.isNotEmpty()) " $attrs" else ""
val childrenStr = children.joinToString("")
return "<$name$attrsStr>$childrenStr</$name>"
}
}
@HtmlDsl
class HTML : Element("html") {
fun head(init: Head.() -> Unit) = initTag(Head(), init)
fun body(init: Body.() -> Unit) = initTag(Body(), init)
}
@HtmlDsl
class Head : Element("head") {
fun title(init: Title.() -> Unit) = initTag(Title(), init)
}
@HtmlDsl
class Title : Element("title") {
operator fun String.unaryPlus() {
// Add text content
}
}
@HtmlDsl
class Body : Element("body") {
fun div(init: Div.() -> Unit) = initTag(Div(), init)
fun p(init: P.() -> Unit) = initTag(P(), init)
}
@HtmlDsl
class Div : Element("div") {
fun p(init: P.() -> Unit) = initTag(P(), init)
}
@HtmlDsl
class P : Element("p")
fun html(init: HTML.() -> Unit): HTML {
return HTML().apply(init)
}
Usage:
val page = html {
head {
title { +"My Page" }
}
body {
div {
attribute("class", "container")
p { +"Hello World!" }
}
}
}
When Should You Use Type-safe Builders?
I don’t use builders for everything. Here’s when they make sense:
Use builders when:
- You have objects with many required fields
- You’re building complex configurations
- You want to prevent runtime errors from missing fields
- You need a clean, readable DSL for your API
Don’t use builders when:
- You have simple data classes with just a few fields
- All fields have reasonable defaults
- You’re building one-off objects that won’t be reused
Making Builders Easier with Delegation
Kotlin’s property delegation can make builders even cleaner:
class RequiredProperty<T> : ReadWriteProperty<Any?, T> {
private var value: T? = null
private var isSet = false
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
if (!isSet) throw IllegalStateException("Property ${property.name} must be set")
return value!!
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
this.value = value
isSet = true
}
}
fun <T> required(): ReadWriteProperty<Any?, T> = RequiredProperty()
class UserBuilder {
var name: String by required()
var email: String by required()
var age: Int by required()
var city: String? = null
fun build() = User(name, email, age, city)
}
fun user(block: UserBuilder.() -> Unit): User {
return UserBuilder().apply(block).build()
}
Now building objects is super clean:
val user = user {
name = "Alice"
email = "alice@example.com"
age = 30
city = "New York"
}
Job Offers
The Bottom Line
Type-safe builders in Kotlin saved me hours of debugging time. Yes, they require more upfront work, but they catch errors at compile time instead of runtime.
The @DslMarker annotation and apply function make building DSLs in Kotlin really enjoyable. You can create APIs that feel like they’re part of the language itself.
Start small. Pick one complex object in your Android app and try building a type-safe builder for it. I bet you’ll find at least one place where you were missing a required field without realizing it.
Kotlin’s builders are especially powerful for configuration objects, test data setup, and anywhere you need a clean, readable API.
Have you used type-safe builders in your Kotlin projects? What patterns have worked well for you? I’d love to hear about your experiences.
For any questions or queries, let’s connect on LinkedIn
This article was previously published on proandroiddev.com


