Data class without default constructor

163 views Asked by At

I have these fields in my data class:

    val year: Int,
    val month: Int? = null,
    val day: Int? = null,

How can I insure via constructor that If day in not-null, month is not null as well? Ideally, I wanna have only 3 constructors without default nullable fields at all.

(year: Int) (year: Int, month: Int) and (year: Int, month: Int, day: Int)
3

There are 3 answers

0
Klitos Kyriacou On BEST ANSWER

You can create a normal class, with a private constructor, and add your overloaded constructors:

class X private constructor(val year: Int, val month: Int?, val day: Int?) {
    constructor(year: Int) : this(year, null, null)
    constructor(year: Int, month: Int) : this(year, month, null)
    constructor(year: Int, month: Int, day: Int) : this(year, month, day as Int?)

    // Optionally create equals, hashCode, toString and componentN methods...
}

If you need any of the extra methods that a data class would have auto-generated, just add them manually, or let your IDE generate them for you.

Rationale

Why did we create a normal class? Well, you could create a data class with a private constructor, but that wouldn't prevent the user of your class from calling the copy function and passing invalid parameters to it, such as a day with a null month.

Unlike toString, hashCode and equals, you cannot override the copy function or disable it in any way. If you declare your class as a data class, you'll get an unwanted copy function that you can't get rid of. With a normal class, you can define your own logic in the copy function, though you probably wouldn't want to define it at all as it's problematic.

2
k314159 On

You can create an interface that resembles a data class in its usage:

sealed interface FlexibleDate {
    val year: Int
    val month: Int?
    val day: Int?

    operator fun component1() = year
    operator fun component2() = month
    operator fun component3() = day
    
    companion object {
        operator fun invoke(year: Int): FlexibleDate = Impl(year, null, null)
        operator fun invoke(year: Int, month: Int): FlexibleDate = Impl(year, month, null)
        operator fun invoke(year: Int, month: Int, day: Int): FlexibleDate = Impl(year, month, day)
    }

     private data class Impl(override val year: Int, override val month: Int?, override val day: Int?) : FlexibleDate
}

The private implementation class Impl provides the correctly generated implementations of toString, equals and hashCode that all data classes have.

The interface provides invoke functions so that you can create instances as though it had constructors. The overloads of invoke won't accept a day without a month.

1
David Soroko On

You could use some sort of factory that would enforce types and perhaps other validation (negative values, etc...). One way of doing this is by using a companion object.

As @gidds commented, while the constructor can be private, "invalid" instances can still be created by using copy()

data class MyDate 
private constructor(val year: Int, val month: Int? = null, val day: Int? = null) {
    companion object {
        fun from(year: Int) = MyDate(year, null, null)
        fun from(year: Int, month: Int) = MyDate(year, month, null)
        fun from(year: Int, month: Int, day: Int) = MyDate(year, month, day)
    }
}

fun main() {
    MyDate.from(2023)
        .also { println(it) } // MyDate(year=2023, month=null, day=null)
    MyDate.from(2023, 11)
        .also { println(it) } // MyDate(year=2023, month=11, day=null)
    MyDate.from(2023, 11, 9)
        .also { println(it) } // MyDate(year=2023, month=11, day=9)
    // Invalid instances can still be created using copy()
    MyDate.from(2023, 11, 9).copy(month = null)
        .also { println(it) } // MyDate(year=2023, month=null, day=9)
}

There are options to create various date object out-of-the-box, I assume that they are not a good fit for what you have in mind.