I am still having trouble with the expressiveness ...
# getting-started
d
I am still having trouble with the expressiveness of Kotlin when it comes to constructors. Here is a simplified example. There is a data class that should store the names of columns in CSV files. However, we want those to always be in uppercase. I guess, this would be a solution for that:
Copy code
data class Column(
    private val _csvName: String,    
    val someOtherInfo: SomeInfo) {    
    
    val csvName: String = _csvName.uppercase()    
    
    init {
    	if (!csvName.startsWith("A")) {            
    		throw IllegalArgumentException("Noooo!")        
    }       
}
How do you properly write documentation for that without scattering the information in several places? After all
_csvName
is just a technical artifact that is required due to Kotlin's syntax. Here is what I mean:
Copy code
/**
 * Some Explanation
 *
 * @param _csvName placeholder to be able to transform the name to uppercase. See following description of [csvName]
 * @param someOtherInfo Some other flags.
 */
data class Column(
How do you handle something like that in your projects?
h
Do you really need a data class in this case? I would use a normal class instead with a normal constructor and an init block.
Or if you just want to write csv files, I would use a dedicated csv library/kotlinx.serialization StringFormat to spit the classes/the business logic from the output.
d
Please do not focus that I mentioned CSV here. That was just an example and it could be anything.
You're right, not using a data class would resolve the constructor issues. But then, I would need to create equals and hashCode.
h
Yeah, but for equals/hashcode you could use poko instead: https://github.com/drewhamilton/Poko
d
Thanks for sharing the link. I did not know that project.
Do I understand correctly: in your code, you would go with a regular class instead?
c
IMO
data class
is very often overused. Currently, your code:
Copy code
Column("foo", …) == Column("FOO", …)
is
false
, which I'm pretty sure isn't what you want. A few techniques, depending on what you're doing; A. Regular class and property initialization
Copy code
class Column(
    csvName: String
) {
    val csvName = csvName.uppercase()
}
B. Private constructor
Copy code
class Column private constructor(
    val column: String,
) {
    constructor(column: String) : this(column.uppercase())
}
The main downside is that both constructors need to have a different signature. You can workaround this by using a
ignored: Unit = Unit
last parameter in the private constructor, which won't have any performance impact and won't be visible in your API, but it's still not beautiful. Alternatively, you can put the secondary constructor as a factory method in the companion object, which is the same at the callsite with a static import. Note that this technique does allow you to make
Column
a
data class
, and
equals
and
hashCode
will behave the way you expect. C. Value class If you have these kinds of "strings that must be uppercase" in many places, creating a dedicated type can solve this.
Copy code
@JvmInline value class UppercaseString private constructor(
    val value: String,
) {
    override fun toString() = value
    companion object {
        fun UppercaseString(value: String) = UppercaseString(value.uppercase())
    }
}

class Column(
    val name: UppercaseString,
)
This is slightly more verbose on the callsite (unless you create a helper factory) but it is more typesafe and explicit to readers. It removes the complexity from
Column
, ensures you never have to verify multiple times, and allows to make
Column
a
data class
.
d
Thanks for the very detailed explanation. That private constructor approach is definitely something that I did not think of before. You mention that
Column("foo", …) != Column("FOO", …)
. That is, because the generated
equals
uses the private
_csvName
instead, isn't it? Thanks for pointing that subtle bug out.
c
Yes
You should always think of
data class
as "something that is identified by the contents of the primary constructor". If you have conversion functions to access the data, it's a code smell that the wrong data is in the constructor. Either it shouldn't be a
data class
(most DTOs probably shouldn't be data classes, it's not like we ever use
==
on them), or the conversion should happen before the primary constructor (either in a secondary constructor or in a factory function)
👍 1
💯 1
m
we use data classes with companion objects. e.g.
Copy code
@ConsistentCopyVisibility
data class AgbCode private constructor(
    val value: String,
) {
    init {
        require(value.matches(REGEX)) { "Ongeldige AGB-code" }
    }

    companion object {
        private val REGEX = Regex("""\d{8}""")
        fun validate(code: String): AgbCode? = if (code.matches(REGEX)) AgbCode(code) else null
    }
}
👍 1