14

When creating a data class I frequently find that I want to transform one of the properties, usually to normalize it or to make a defensive copy. For example, here I want productCode to always be lowercase:

data class Product(val productCode: String)

I've tried adding an init block, in the hopes that Kotlin would be smart enough to let me manually deal with the assignment of the constructor parameter to the property:

data class Product(val productCode: String) {
    init {
        this.productCode = productCode.toLowerCase()
    }
}

but it treats this as a reassignment.

I'd rather not have to write equals/hashCode/toString/copy by hand and IDE generated methods aren't really much better.

Is there any way to transform constructor parameters in a data class?

Laurence Gonsalves
  • 137,896
  • 35
  • 246
  • 299

2 Answers2

15

No. For equality and toString to work, the properties need to be in the primary constructor.

What you can do however, is create a factory method:

data class Product private constructor(val productCode: String) {

  companion object Factory {
     fun create(productCode: String) : Product {
        return Product(productCode.toLowerCase())
     }
  }
}

By making the constructor private you force usage of this create method.

If you want to get 'hacky', you can pretend you're still calling the constructor, by renaming create to invoke and making it an operator function:

data class Product private constructor(val productCode: String) {

    companion object {

        operator fun invoke(productCode: String): Product {
            return Product(productCode.toLowerCase())
        }
    }
}

Calling Product("foo") will call the invoke method.


Note: the constructor is still exposed through the copy method, see https://youtrack.jetbrains.com/issue/KT-11914

nhaarman
  • 98,571
  • 55
  • 246
  • 278
  • The factory method is an interesting idea, but the fact that `copy` acts as a back door is a pretty serious flaw. I don't want it to be possible for an object with an un-normalized property to exist. I could add a `require(...)` in `init`, but by that point the amount of extra code has reached the level where using a regular class is probably the lesser evil. – Laurence Gonsalves Mar 29 '18 at 18:14
  • 1
    I don't think using `invoke` like this is at all hacky. – Alexey Romanov Mar 30 '18 at 12:25
  • Does this approach work well with libraries, e.g. if we want to deserialize this object from JSON (and constructor is not available)? Need to check for each specific library of course, but I'm seeing how Jackson or Hibernate using byte-code manipulation calls private constructor anyway, and we end up with non-validated object. – Ivan Balashov Mar 22 '23 at 09:09
3

What about

sealed class Product {
    abstract val productCode: String

    private data class Product(override val productCode: String) : your.package.Product()

    companion object {
        operator fun invoke(productCode: String): your.package.Product = 
            Product(productCode.toLowerCase())
    }
}

All the advantages of data class without exposing copy. A negative is having to repeat property names an extra time.

Alexey Romanov
  • 167,066
  • 35
  • 309
  • 487
  • Very nice one! Nitpicky: the `toString` method will print `ProductImpl(productCode="...")` instead of `Product`. – nhaarman Apr 01 '18 at 22:32
  • Oops, you are right. Edited the answer to fix this. – Alexey Romanov Apr 02 '18 at 07:59
  • I think this is *really* clever, but you end up having to state the full set of properties 4 times, which is the same as if you just use a regular class and implement `equals`, `hashCode` and `toString` yourself. The more properties you need to normalize, the longer the regular class approach gets, while this approach stays the same, but it's still really close. – Laurence Gonsalves Apr 03 '18 at 16:50
  • 1
    But the greatest problem with repeating properties in `equals` and friends (IMO) is that you can miss one or forget to edit all of them when a new property is added. In this approach, missing property in one of places is a compilation error. – Alexey Romanov Apr 03 '18 at 17:42