Skip to main content
kotlin in depth advanced patterns for java engineers

Building Type-Safe Builders

9 min read Chapter 8 of 21

Building Type-Safe Builders

Here’s the API we’re going to build — a type-safe DSL for defining REST API routes:

val api = restApi("v2") {
    resource("users") {
        get { 
            query("page" to int, "limit" to int.optional(default = 20))
            returns<List<User>>()
        }
        get("/{id}") {
            pathParam("id" to uuid)
            returns<User>()
        }
        post {
            body<CreateUserRequest>()
            returns<User>(status = 201)
        }
        resource("posts") {
            get {
                query("since" to instant.optional())
                returns<List<Post>>()
            }
        }
    }
    middleware {
        authenticate("bearer")
        rateLimit(requests = 100, per = Duration.ofMinutes(1))
    }
}

This compiles to a fully validated route tree. Missing required parameters cause compile-time errors. Invalid nesting (putting middleware inside a get) is rejected by the type system. The entire DSL compiles down to direct constructor calls with zero allocation overhead.

Let’s build it layer by layer, starting from the bottom.

Step 1: Design the Output Model

Before writing any DSL code, define what the builder produces. This is the domain model — the DSL is an ergonomic front-end for constructing these objects:

data class ApiDefinition(
    val version: String,
    val resources: List<Resource>,
    val middlewares: List<Middleware>
)

data class Resource(
    val path: String,
    val endpoints: List<Endpoint>,
    val children: List<Resource>
)

data class Endpoint(
    val method: HttpMethod,
    val subPath: String,
    val params: List<ParamDef>,
    val requestBody: KClass<*>?,
    val responseType: KClass<*>,
    val responseStatus: Int
)

data class ParamDef(
    val name: String,
    val type: ParamType,
    val required: Boolean,
    val default: Any?
)

In Java, you’d construct these with nested builders:

ApiDefinition api = ApiDefinition.builder("v2")
    .resource(Resource.builder("users")
        .endpoint(Endpoint.builder(GET)
            .queryParam("page", ParamType.INT, true, null)
            .queryParam("limit", ParamType.INT, false, 20)
            .responseType(List.class)
            .responseStatus(200)
            .build())
        .build())
    .build();

This is structurally correct but visually indistinguishable from noise at scale. The Kotlin DSL preserves the same structural guarantees while reading like a specification document.

Step 2: The @DslMarker Annotation

Define the scope boundary first. This single annotation prevents every implicit receiver confusion that would otherwise plague the DSL:

@DslMarker
annotation class RestApiDsl

Every builder class in the DSL gets this annotation. The compiler then enforces: inside any @RestApiDsl-annotated receiver scope, you cannot implicitly access members of an outer @RestApiDsl-annotated receiver. You must use an explicit this@label reference.

Without @DslMarker, this code would compile:

restApi("v2") {
    resource("users") {
        get {
            resource("nested") { } // Resolves against outer ResourceBuilder — wrong!
        }
    }
}

With @DslMarker, calling resource inside get { } is a compile error because EndpointBuilder is the current receiver, and ResourceBuilder.resource() is on an outer receiver in the same DSL family. The user sees:

error: 'fun resource(path: String, init: ResourceBuilder.() -> Unit)' can't be called 
in this context by implicit receiver. Use the explicit receiver 'this@resource'.

Step 3: Builder Classes

Each builder collects configuration and produces a domain object. Start at the leaves and work upward.

EndpointBuilder

@RestApiDsl
class EndpointBuilder(
    private val method: HttpMethod,
    private val subPath: String = ""
) {
    private val params = mutableListOf<ParamDef>()
    private var requestBodyType: KClass<*>? = null
    private var responseType: KClass<*> = Unit::class
    private var responseStatus: Int = 200

    fun query(vararg defs: Pair<String, ParamSpec>) {
        defs.forEach { (name, spec) ->
            params.add(ParamDef(name, spec.type, spec.required, spec.default))
        }
    }

    fun pathParam(vararg defs: Pair<String, ParamSpec>) {
        defs.forEach { (name, spec) ->
            params.add(ParamDef(name, spec.type, required = true, default = null))
        }
    }

    inline fun <reified T : Any> body() {
        requestBodyType = T::class
    }

    inline fun <reified T : Any> returns(status: Int = 200) {
        responseType = T::class
        responseStatus = status
    }

    fun build(): Endpoint = Endpoint(
        method = method,
        subPath = subPath,
        params = params.toList(),
        requestBody = requestBodyType,
        responseType = responseType,
        responseStatus = responseStatus
    )
}

Notice: body<CreateUserRequest>() and returns<User>() use reified type parameters. They’re inline functions, so the type information survives erasure. In Java, you’d need to pass CreateUserRequest.class explicitly — here the type is inferred from the generic parameter.

ResourceBuilder

@RestApiDsl
class ResourceBuilder(private val path: String) {
    private val endpoints = mutableListOf<Endpoint>()
    private val children = mutableListOf<Resource>()

    fun get(subPath: String = "", init: EndpointBuilder.() -> Unit = {}) {
        val builder = EndpointBuilder(HttpMethod.GET, subPath)
        builder.init()
        endpoints.add(builder.build())
    }

    fun post(subPath: String = "", init: EndpointBuilder.() -> Unit = {}) {
        val builder = EndpointBuilder(HttpMethod.POST, subPath)
        builder.init()
        endpoints.add(builder.build())
    }

    fun put(subPath: String = "", init: EndpointBuilder.() -> Unit = {}) {
        val builder = EndpointBuilder(HttpMethod.PUT, subPath)
        builder.init()
        endpoints.add(builder.build())
    }

    fun delete(subPath: String = "", init: EndpointBuilder.() -> Unit = {}) {
        val builder = EndpointBuilder(HttpMethod.DELETE, subPath)
        builder.init()
        endpoints.add(builder.build())
    }

    fun resource(path: String, init: ResourceBuilder.() -> Unit) {
        val builder = ResourceBuilder(path)
        builder.init()
        children.add(builder.build())
    }

    fun build(): Resource = Resource(
        path = path,
        endpoints = endpoints.toList(),
        children = children.toList()
    )
}

The pattern is consistent: create a builder, execute the lambda with the builder as receiver, call build(), and store the result. Every method that takes a lambda uses the T.() -> Unit signature.

ApiBuilder

@RestApiDsl
class ApiBuilder(private val version: String) {
    private val resources = mutableListOf<Resource>()
    private val middlewares = mutableListOf<Middleware>()

    fun resource(path: String, init: ResourceBuilder.() -> Unit) {
        val builder = ResourceBuilder(path)
        builder.init()
        resources.add(builder.build())
    }

    fun middleware(init: MiddlewareBuilder.() -> Unit) {
        val builder = MiddlewareBuilder()
        builder.init()
        middlewares.addAll(builder.build())
    }

    fun build(): ApiDefinition = ApiDefinition(
        version = version,
        resources = resources.toList(),
        middlewares = middlewares.toList()
    )
}

Step 4: The Entry Point

fun restApi(version: String, init: ApiBuilder.() -> Unit): ApiDefinition {
    val builder = ApiBuilder(version)
    builder.init()
    return builder.build()
}

One function. It creates the root builder, executes the user’s lambda with that builder as this, and returns the finished product. This is the pattern for every DSL entry point you’ll ever write.

Step 5: Type-Safe Parameter Specifications

The query("page" to int) syntax works through infix functions and a small type vocabulary:

sealed class ParamType {
    object IntType : ParamType()
    object StringType : ParamType()
    object UuidType : ParamType()
    object InstantType : ParamType()
    object BoolType : ParamType()
}

data class ParamSpec(
    val type: ParamType,
    val required: Boolean = true,
    val default: Any? = null
)

// DSL vocabulary — these are top-level vals
val int = ParamSpec(ParamType.IntType)
val string = ParamSpec(ParamType.StringType)
val uuid = ParamSpec(ParamType.UuidType)
val instant = ParamSpec(ParamType.InstantType)
val bool = ParamSpec(ParamType.BoolType)

fun ParamSpec.optional(default: Any? = null) = copy(required = false, default = default)

The "page" to int expression uses Kotlin’s built-in to infix function, producing a Pair<String, ParamSpec>. The DSL vocabulary (int, uuid, instant) is a set of pre-built ParamSpec instances. Adding .optional(default = 20) returns a copy with required = false.

Compile-Time Validation with Sealed Types

You can push validation into the type system so that invalid configurations don’t compile. Consider requiring that every post endpoint must declare a request body:

@RestApiDsl
class PostEndpointBuilder(subPath: String) {
    private var requestBodyType: KClass<*>? = null
    // ... other fields ...

    inline fun <reified T : Any> body() {
        requestBodyType = T::class
    }

    fun build(): Endpoint {
        requireNotNull(requestBodyType) {
            "POST endpoints must declare a request body. Use body<YourType>()."
        }
        // ... build endpoint ...
    }
}

This catches the error at DSL evaluation time with a clear message. For true compile-time enforcement, you can use a phantom type approach:

sealed interface BodyState
object NoBody : BodyState
object HasBody : BodyState

@RestApiDsl
class TypedPostBuilder<B : BodyState> {
    // body() is only callable when B is NoBody, and it returns a builder with HasBody
}

This technique trades some complexity for compile-time safety. Use it when the DSL is part of a library consumed by other teams — the compile error is far more helpful than a runtime exception.

Common Mistakes

Mistake 1: Forgetting @DslMarker on all builders. If you annotate ResourceBuilder but not EndpointBuilder, the scope control won’t apply between them. Every class that participates as a receiver in the DSL must carry the annotation.

Mistake 2: Not making builder functions inline. For DSLs used in tight loops or hot paths, non-inline builder functions allocate a lambda object per call. Mark builder-accepting functions as inline to eliminate this:

// Without inline: allocates a Function object for `init`
fun get(subPath: String = "", init: EndpointBuilder.() -> Unit) { ... }

// With inline: lambda body is copied to the call site
inline fun get(subPath: String = "", init: EndpointBuilder.() -> Unit) { ... }

Mistake 3: Mutable state leaking across builds. If you reuse a builder instance, state from the first build() call contaminates the second. Always create a fresh builder per DSL block, or clear state in build().

Mistake 4: Overloading the DSL vocabulary. Resist the temptation to add every possible configuration option as a DSL function. DSLs should cover the 80% case with clear syntax. Escape hatches (like a raw { } block or direct builder access) handle the rest.

The Complete, Runnable Example

Putting it together with inline for zero overhead:

@DslMarker
annotation class RestApiDsl

@RestApiDsl
class ApiBuilder(private val version: String) {
    private val resources = mutableListOf<Resource>()

    inline fun resource(path: String, init: ResourceBuilder.() -> Unit) {
        val builder = ResourceBuilder(path)
        builder.init()
        resources.add(builder.build())
    }

    fun build(): ApiDefinition = ApiDefinition(version, resources.toList())
}

@RestApiDsl
class ResourceBuilder(private val path: String) {
    private val endpoints = mutableListOf<Endpoint>()
    private val children = mutableListOf<Resource>()

    inline fun get(subPath: String = "", init: EndpointBuilder.() -> Unit = {}) {
        endpoints.add(EndpointBuilder(HttpMethod.GET, subPath).apply(init).build())
    }

    inline fun post(subPath: String = "", init: EndpointBuilder.() -> Unit = {}) {
        endpoints.add(EndpointBuilder(HttpMethod.POST, subPath).apply(init).build())
    }

    inline fun resource(path: String, init: ResourceBuilder.() -> Unit) {
        children.add(ResourceBuilder(path).apply(init).build())
    }

    fun build(): Resource = Resource(path, endpoints.toList(), children.toList())
}

@RestApiDsl
class EndpointBuilder(private val method: HttpMethod, private val subPath: String) {
    private val params = mutableListOf<ParamDef>()
    private var responseType: KClass<*> = Unit::class
    private var responseStatus: Int = 200

    fun query(vararg defs: Pair<String, ParamSpec>) {
        defs.forEach { (name, spec) -> params.add(ParamDef(name, spec.type, spec.required, spec.default)) }
    }

    inline fun <reified T : Any> returns(status: Int = 200) {
        responseType = T::class
        responseStatus = status
    }

    fun build(): Endpoint = Endpoint(method, subPath, params.toList(), responseType, responseStatus)
}

inline fun restApi(version: String, init: ApiBuilder.() -> Unit): ApiDefinition {
    return ApiBuilder(version).apply(init).build()
}

Usage:

fun main() {
    val api = restApi("v2") {
        resource("users") {
            get {
                query("page" to int, "limit" to int.optional(default = 20))
                returns<List<User>>()
            }
            post {
                returns<User>(status = 201)
            }
            resource("posts") {
                get {
                    returns<List<Post>>()
                }
            }
        }
    }

    // api is now a fully constructed, immutable ApiDefinition
    api.resources.forEach { resource ->
        println("/${api.version}/${resource.path}")
        resource.endpoints.forEach { endpoint ->
            println("  ${endpoint.method} ${endpoint.subPath}")
        }
    }
}

Every lambda call in this tree is inlined. The compiled bytecode creates ApiBuilder, ResourceBuilder, and EndpointBuilder instances, calls their methods directly, and produces the final ApiDefinition. No lambda objects. No anonymous classes. The DSL syntax is a compile-time abstraction that vanishes in the output.