Building Type-Safe Builders
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.