I wrote about a problem I was having with Kotlin data class constructors a few days ago. I sent it to a few people and asked for thoughts on how they might address the issue.

A few people proposed a few different possible solutions to the problem. I thought I’d write them down here, as well as what I ended up doing.

Make constructor private

One proposed solution was to make the constructor private and instead have a factory method:

data class User private constructor (
    val userId: UUID,
    val name: String,
    val emailId: String
) {
    companion object {
        fun create(
            userId: UUID,
            name: String,
            emailId: String
        ) {
            return User(
                userId = userId,
                name = name.trim(),
                emailId = emailId.toLowerCase()
            )
        }
    }
}

On the face of it, this looks like a good solution. However, there is a problem with it that IDEA very nicely warns you about: the copy method.

Even if you make the constructor private, it is still exposed by the copy method that is generated by the compiler.

You could still write something like this:

val user = User.create(UUID.randomUUID(), "Foo", "foo@bar.com")
user.copy(emailId = "FOO@bar.com")

which is problematic.

Make fields private

Another proposed solution looks like this:

data class User(
    val userId: UUID,
    private var _name: String,
    private val _emailId: String
) {
    val name: String
        get() = _name.trim()

    val emailId: String
        get() = _emailId.toLowerCase()
}

There’re unfortunately a couple of problems with this as well. First, every time you read name, or emailId you end up creating a whole new object, which means more garbage. This seems unnecessarily wasteful.

Second, the call site becomes ugly:

val user = User(
    userId = UUID.randomUUID(),
    _name = "Foo",
    _emailId = "foo@bar.com"
)

Admittedly, this is not a problem if you don’t use named parameters. However, named parameters are super useful, especially when you have model objects (the obvious use case for data classes) with > 5 parameters. It significantly reduces the likelihood of making a mistake.

Eventual non solution

Eventually, I wasn’t able to find any solutions to this problem that still allowed me to use data classes. So I ended up not using a data class, and instead writing out all the boilerplate necessary:

class User private constructor(
    val userId: UUID,
    val name: String,
    val emailId: String
) {

    companion object {
        fun create(
            userId: UUID,
            name: String,
            emailId: String,
        ): User {
            return User(
                userId = userId,
                name = name.trim(),
                emailId = emailId.toLowerCase().trim()
            )
        }
    }

    override fun equals(other: Any?): Boolean {
        // Have IDEA generate this for me
        // Remember to re-generate it everytime I add a property to this class
    }

    override fun hashCode(): Int {
        // Have IDEA generate this for me
        // Remember to re-generate it everytime I add a property to this class
    }

    // Unfortunately IDEA cannot generate this for me, so type it out
    // I might eventually create a live template for this
    fun copy(
        userId: UUID = this.userId,
        name: String = this.name,
        emailId: String = this.emailId
    ): User {
        return create(
            userId,
            name.trim(),
            emailId.toLowerCase().trim()
        )
    }
}

Not ideal, but it works…

If you have a better solution in mind, please do let me know: @gopalkri!