In Kotlin, when creating a custom DSL, what is the best way to force filling required fields inside the builder's extension functions in compile time. E.g.:
person {
name = "John Doe" // this field needs to be set always, or compile error
age = 25
}
One way to force it is to set value in a function parameter instead of the body of the extension function.
person(name = "John Doe") {
age = 25
}
but that makes it a bit more unreadable if there are more required fields.
Is there any other way?
New type inference enables you to make a null-safe compile-time checked builder:
data class Person(val name: String, val age: Int?)
// Create a sealed builder class with all the properties that have default values
sealed class PersonBuilder {
var age: Int? = null // `null` can be a default value if the corresponding property of the data class is nullable
// For each property without default value create an interface with this property
interface Named {
var name: String
}
// Create a single private subclass of the sealed class
// Make this subclass implement all the interfaces corresponding to required properties
private class Impl : PersonBuilder(), Named {
override lateinit var name: String // implement required properties with `lateinit` keyword
}
companion object {
// Create a companion object function that returns new instance of the builder
operator fun invoke(): PersonBuilder = Impl()
}
}
// For each required property create an extension setter
fun PersonBuilder.name(name: String) {
contract {
// In the setter contract specify that after setter invocation the builder can be smart-casted to the corresponding interface type
returns() implies (this@name is PersonBuilder.Named)
}
// To set the property, you need to cast the builder to the type of the interface corresponding to the property
// The cast is safe since the only subclass of `sealed class PersonBuilder` implements all such interfaces
(this as PersonBuilder.Named).name = name
}
// Create an extension build function that can only be called on builders that can be smart-casted to all the interfaces corresponding to required properties
// If you forget to put any of these interface into where-clause compiler won't allow you to use corresponding property in the function body
fun <S> S.build(): Person where S : PersonBuilder, S : PersonBuilder.Named = Person(name, age)
Use case:
val builder = PersonBuilder() // creation of the builder via `invoke` operator looks like constructor call
builder.age = 25
// builder.build() // doesn't compile because of the receiver type mismatch (builder can't be smart-casted to `PersonBuilder.Named`)
builder.name("John Doe")
val john = builder.build() // compiles (builder is smart-casted to `PersonBuilder & PersonBuilder.Named`)
Now you can add a DSL function:
// Caller must call build() on the last line of the lambda
fun person(init: PersonBuilder.() -> Person) = PersonBuilder().init()
DSL use case:
person {
name("John Doe") // will not compile without this line
age = 25
build()
}
Finally, on JetBrains open day 2019 it was said that the Kotlin team researched contracts and tried to implement contracts that will allow creating safe DSL with required fields. Here is a talk recording in Russian. This feature isn't even an experimental one, so maybe it will never be added to the language.
In case you're developing for Android I wrote a lightweight linter to verify mandatory DSL attributes.
To solve your use case you will only need to add an annotation @DSLMandatory
to your name
property setter and the linter will catch any place when it is not assigned and display an error:
@set:DSLMandatory
var name: String
You can take a look here: https://github.com/hananrh/dslint/
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With