Override only specified val property defaults?

Hi, new to Kotlin here! I am wondering if Kotlin provides a type-safe way to construct an object whose properties are vals by providing only a dynamically loaded list of parameters to override, while leaving the others as their defaults. I provide one possible solution below, but I would love to hear improvements or alternative approaches.

To demonstrate what I am talking about, consider the following class (ignoring data classes to simplify the discussion):

class Settings(
    text1:String = "aaaa"
    text2:String = "bbbb"
    text3:String = "cccc"
    num1:Int = 1,
    num2:Int = 2,
    num3:Int = 3) {

    val text1:String = text1
    val text2:String = text2
    val text3:String = text3
    val num1:Int = num1
    val num2:Int = num2
    val num3:Int = num3
}

In an application, we might want to populate such an object from a variety of sources: configuration files, user input, an API call, etc. So, we want to keep the default values in one place (presumably the constructor arguments, where the caller can see them) rather than spreading them throughout the application. So far, so good.

However, say that there are many properties, and that typically only a few overrides to the defaults are provided dynamically at runtime. The question is, what is the best way to construct an instance of this class that satisfies the following:

  • maintain strict type safety
  • use vals for the properties to make the settings immutable
  • keep the default property values all in one place
  • require the caller to provide only the properties that they want to override

Kotlin provides a multitude of ways to satisfy three of the above conditions, but covering all four seems a bit difficult. Here is the best approach that I have come up with so far:

import kotlin.reflect.KProperty1

class SetterPair<TClass, Data>(val prop:KProperty1<TClass, TData>, val value:TData)

fun <TClass, TData, TProp:KProperty1<TClass, TData>> propSetterFromValue(prop:TProp)
    : (TData) -> PropValue<TClass, TData>
    = { value:TData -> SetterPair<TClass, TData>(prop, value) }

class Settings(
    vararg setters:SetterPair<Settings, *>,
    text1:String = "aaaa"
    text2:String = "bbbb"
    text3:String = "cccc"
    num1:Int = 1
    num2:Int = 2
    num3:Int = 3) {

    val text1:String
    val text2:String
    val text3:String
    val num1:Int
    val num2:Int
    val num3:Int

    init {
        fun <TData, TProp:KProperty1<Settings, TData>>
        setterOrDefault(prop:TProp, default:TData)
        : TData {
            return (setters.firstOrNull{ it.prop == prop }?.value as TData?) ?: defaultValue
        }

        this.text1 = setterOrDefault(Settings::text1, text1)
        this.text2 = setterOrDefault(Settings::text2, text2)
        this.text3 = setterOrDefault(Settings::text3, text3)
        this.num1 = setterOrDefault(Settings::num1, num1)
        this.num2 = setterOrDefault(Settings::num2, num2)
        this.num3 = setterOrDefault(Settings::num3, num3)
    }

    override fun toString(): String = "{text1:${text1}; text2:${text2}; text3:${text3}; num1:${num1}; num2:${num2}; num3:${num3}}"
}

fun main() {
    val defaultSettings = Settings()
    val setter1 = propSetterFromValue(Settings::text2)("zzzz")
    val setter2 = propSetterFromValue(Settings::num3)(-1)
    val customSettings = Settings(setter1, setter2)
    print("$defaultSettings\n")
    print("$customSettings\n")
}

This approach satisfies three of the four conditions that I mentioned above and partially satisfies the fourth. It stores the properties as vals, it keeps the property defaults in the constructor arguments, and it requires the caller to specify only the properties that they want to change from their defaults.

On the matter of type-safety, this implementation does prevent the following mistakes from compiling:

val badSetter = propSetterFromValue(Settings::text2)(-1)
this.text2 = setterOrDefault(Settings::text2, num3)

The curried form for building SetterPairs is done to prevent the typechecker from seeing a mismatched KProperty1 and TData and inferring TData as Any/Any?: by inferring the type from the KProperty1 alone first, it will infer the specific datatype that we want to assign. However, if the caller intentionally specifies the type argments, they could short-circuit this logic:

val badSetter = propSetterFromValue<Settings, Any?, KProperty1<Settings, Any?>>(Settings::text2)(-1)

The preceding line will compile, but will result in a runtime error. I don’t see an obvious way to prevent this issue. I understand the difficulty to be that the second type argument to KProperty is an out type, so a KProperty1<SomeClass, Any?> is fundamentally assignable to a KProperty1<SomeClass, SomeData> for any value of SomeData. Any ideas on how to close this loophole?

That failing aside, this implementation isn’t perfect. I was able to keep the caller side fairly simple, but it could be simpler. Also, it would be nice if it wasn’t possible for the caller to accidentally specify the same property twice. Finally, if two properties have the same type, then the implementer of the Settings.init method could mistakenly assign the value or default of one parameter to the other; it would be nice if they were more strongly bound.

Any ideas for alternative solutions or feedback on mine are welcome. Thanks!

Hi, just to be clear, would named arguments be considered a possibility?

class Settings(
    val text1: String = "aaaa",
    val text2: String = "bbbb",
    val text3: String = "cccc",
    val num1: Int = 1,
    val num2: Int = 2,
    val num3: Int = 3
) {
    override fun toString(): String = "{text1:${text1}; text2:${text2}; text3:${text3}; num1:${num1}; num2:${num2}; num3:${num3}}"
}

fun main() {
    val defaultSettings = Settings()
    val customSettings = Settings(
        text2 = "zzzz",
        num3 = -1
    )
    print("$defaultSettings\n")
    print("$customSettings\n")
}

While named arguments are nice for a lot of things, they have the limitation that you must hard-code which named arguments are being supplied to the constructor. If there are many arguments and you don’t know which defaults will be overridden until run time (for example, if they come from user input), then I don’t think named arguments really help.

For unspecified numbers of different parameter overrides I would probably look into builder classes, and move all default values into the builder class.

If you don’t want to implement builder classes, here are a few things i would change in your implementation:

Move TProp out of type arguments, since types shouldn’t be specified here anyway. It does not close the loophole, but IMO improves readability.

fun <TClass, TData> propSetterFromValue(prop: KProperty1<TClass, TData>)
    : (TData) -> PropValue<TClass, TData> =
    { value: TData -> PropValue(prop, value) }

Move setterOrDefault outside of init, preferably into global scope, to avoid duplication. Add a type check to “close the loophole”. It is still a possibility to specify wrong types for the property, but they would get discarded instead of throwing runtime errors. Don’t use firstOrNull to allow for null as an override. This is an extension function for varargs PropValue<TClass, *>.

inline fun <TClass, reified TData> Array<out PropValue<TClass, *>>.getOrDefault(prop: KProperty1<TClass, TData>, default: TData): TData =
    filter { it.prop == prop && it.value is TData }.let { if (it.isEmpty()) default else it.first().value as TData }

If properties should not be specified multiple times you can add something like this in init, although there probably is a better way. require throws an IllegalArgumentException, should the value be false.

require(setters.distinctBy { it.prop }.size == setters.size)
2 Likes