0

Suppose I want to create a generic class for collecting validation errors of entity's field.

For clarity let's define 2 independent entity classes:

class Person(val name: String, val age: UInt)  
class Employer(val name: String, val registrationDate: LocalDate)

Let's create a universal interface to denote one single field and the knowledge about how to access the value of that single field:

/**
 * @param T entity type
 * @param U type of field value
 */
sealed interface EntityField<T, out U> {
    fun extractValue(ent: T): U
}

Let's now define a dedicated types for fields of each specific entity and concrete objects responsible for fields:

/**
 * @param T type of person's field value
 */
sealed interface PersonFields<T> : EntityField<Person, T> {
    object PersonName : EntityField<Person, String> {
        override fun extractValue(ent: Person) = ent.name
    }

    object PersonAge : EntityField<Person, UInt> {
        override fun extractValue(ent: Person) = ent.age
    }
}

/**
 * @param T type of employer's field value
 */
sealed interface EmployerFields<T> : EntityField<Employer, T> {
    object EmployerName : EntityField<Employer, String> {
        override fun extractValue(ent: Employer) = ent.name
    }

    object EmployerAge : EntityField<Employer, LocalDate> {
        override fun extractValue(ent: Employer) = ent.registrationDate
    }
}

Sidenote: sealed keyword interfaces will force the compiler to perform exhaustiveness check and avoid else branch in where expression when I will map entity's fields on their corresponding jsonpath coordinates in web-layer during validation errors reporting. sealed do not affect the question in any way.

Now let's think about the design of the errors collector for entity's fields. It's basically a wrapper over the Map but with enforced types which will prevent it's clients from mistakes at compile-time.

/**
 * @param T entity type
 * @param U field type
 */
class ValidationErrorsCollector<T, U : EntityField<T, Any>> {
    private val map: MutableMap<U, String> = hashMapOf()

    fun addErrorIfStringFieldIsEmpty(field: EntityField<T, CharSequence>, ent: T) {
        val value = field.extractValue(ent)
        if (value.isEmpty()) {
        // here is the problem. You need an unsafe cast `field as U` to compile
            map[field] = "value can't be empty"
        }
    }

    val collectedErrors: Map<U, String>
        get() = map
}

As you can see, there are 2 type arguments. The first one defines the type of entity for which the collector is capable of collecting validation errors. The 2nd one is a type of fields for which validation errors can be collected. The 2nd argument is bound to the 1st one, because you obviously do not want to be able to attempt to store errors of Person's fields if ValidationErrorsCollector expect to work with Employer and it's field. There is also one public method with type restrictions on it's arguments, which allows to record an error for any empty CharSequence field of an entity.

You might argue that ValidationErrorsCollector does too much (not only collects, but also performs the validation itself) and it most probably would be better to perform the validation of text field somewhere outside the of the collector class, but for the sake of the example of this question let's pretend that we do not want to refactor, because I am interested in how to solve the problem with types here.

So, I expect the class client's to use it safely as follows:

// define types as
val personErrorsCollector: ValidationErrorsCollector<Person, PersonFields<Any>> = ValidationErrorsCollector()
val employerErrorsCollector: ValidationErrorsCollector<Employer, EmployerFields<Any>> = ValidationErrorsCollector()

// the following won't work out, because Employer can't have PersonFields:
ValidationErrorsCollector<Employer, PersonFields<Any>>

// expected usage:
personErrorsCollector.addErrorIfStringFieldIsEmpty(
    field = PersonFields.PersonName,
    ent = Person(name = "", age = 42U)
)

// the following won't work, because only CharSequence fields can be checked for emptiness. Good!
personErrorsCollector.addErrorIfStringFieldIsEmpty(
    field = PersonFields.PersonAge,
    ent = Person(name = "", age = 42U)
)

Looks good and safe and I am quite satisfied with the result, but there is the problem :(. If you did not notice yet, there will be the compilation problem on the following line:

// requires `field as U` cast to compile
map[field] = "value can't be empty"

And I understand why... The U type argument of ValidationErrorsCollector considers any subtypes of EntityField<T, Any> as a valid value. What if we try to define a variable for errors collector as:

val invalidCollector: ValidationErrorsCollector<Person, PersonFields<Number>> = ValidationErrorsCollector()

Unfortunately, the compiler won't complain on this combination of type arguments, but this is a serious problem. I would want to avoid this situation, because if you define the collector to expect only numbers, the addErrorIfStringFieldIsEmpty method does not make sense. And if you try do the following:

val invalidCollector: ValidationErrorsCollector<Person, PersonFields<Number>> = ValidationErrorsCollector()
    invalidCollector.addErrorIfStringFieldIsEmpty(
        field = PersonFields.PersonName,
        ent = Person(name = "", age = 42U),
    )
    val firstNumberFieldWithError = invalidCollector.collectedErrors.keys.first()

the code will explode on last line with ClassCastException, because PersonFields.PersonName can't be casted to PersonFields<Number>.

Even though my initial problem was the necessity for unsafe cast with ugly @Suppress at map[field] = "value can't be empty", the investigation of the problem has led me to the possibility of illegal combination of ValidationErrorsCollector<Person, PersonFields<Number>>.

How can I try to solve this problem?

  • One idea which came to mind was: let's make the base EntityField interface invariant by removing out from U type argument. This would limit the U type argument of ValidationErrorsCollector to subtypes of EntityField<Any>, where Any can't be replaced by it's subtypes, so the following would not work out: ValidationErrorsCollector<Person, PersonFields<Number>>. Because PersonFields<Number> would not be a subtype of EntityField<Person, Any> anymore. But if we make EntityField invariant by removing out from it's 2nd type argument, we won't be able to use EntityField<T, CharSequence> as a key for the internal map which expects subtypes of EntityField<SomeEntity, Any>. So, we can't remove out from EntityField

  • Another idea was to replace type of the internal map from MutableMap<U, String> to MutableMap<EntityField<T, Any>, String>. It would solve the problem with the need for unsafe cast on write to map, but another compiler problem appears in collectedErrors property. It expects keys of the resulting immutable map to be of type U, but I just redeclared types of the key for internal map to be EntityField<T, Any> instead of U. How it can lead the to the problem? It was already mentioned that you can define type arguments for errors collector to be ValidationErrorsCollector<Person, PersonFields<Number>>, but it's not a legit combination. The addErrorIfStringFieldIsEmpty puts PersonFields<String> key to the internal map, but our incorrect types combinations leads to expectation from collectedErrors to contain only PersonFields<Number>. PersonFields<Number> and PersonFields<String> are invariant types, hence the another compiler error.

So, my attempts to solve the problem with the unsafe cast on write to the internal map:

map[field as U] = "value can't be empty"

lead me to another problem. I must prohibit the illegal combination of type arguments for ValidationErrorsCollector. ValidationErrorsCollector<SomeEntity, SomeEntityFields<Number>> is not legit, only ValidationErrorsCollector<SomeEntity, SomeEntityFields<Any>> must be allowed. There is a constraint for the 2nd type argument of ValidationErrorsCollector: U : EntityField<T, Any>, but it's not enough, because the 2nd type argument EntityField has out modifier, which makes it covariant and allows any covariant subtypes.

I need to put more powerful constraint on U to allow only those subtypes, as if EntityField was invariant. Is it possible to achieve with Kotlin? If not, I would be happy to hear any other solutions/workarounds to the problem, except for extraction of the addErrorIfStringPropertyIsEmpty somewhere else (which would significantly simplify the problem by removing the first type argument of ValidationErrorsCollector, which is the type of entity to validate).

Kirill
  • 6,762
  • 4
  • 51
  • 81
  • Could you please elaborate on why `ValidationErrorsCollector>` is not legit? Technically the main issue here is that your method `addErrorIfStringFieldIsEmpty` doesn't care about `U`. Either it should care about it in its signature, or you should just remove `U` completely from the definition of `ValidationErrorsCollector`. I don't see the reason for `U`'s existence in the first place to be frank. – Joffrey Oct 28 '22 at 09:26
  • Also, is it on purpose that `PersonFields.PersonName` is-not-a `PersonFields`? You defined all those nested classes as implementing `EntityField` directly, not the restricted interface. – Joffrey Oct 28 '22 at 09:27

0 Answers0