Blog Infos
Author
Published
Topics
, , , ,
Published

 

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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

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

https://www.linkedin.com/in/devbaljeet/

This article was previously published on proandroiddev.com

Menu