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 removingout
fromU
type argument. This would limit theU
type argument ofValidationErrorsCollector
to subtypes ofEntityField<Any>
, whereAny
can't be replaced by it's subtypes, so the following would not work out:ValidationErrorsCollector<Person, PersonFields<Number>>
. BecausePersonFields<Number>
would not be a subtype ofEntityField<Person, Any>
anymore. But if we makeEntityField
invariant by removingout
from it's 2nd type argument, we won't be able to useEntityField<T, CharSequence>
as a key for the internal map which expects subtypes ofEntityField<SomeEntity, Any>
. So, we can't removeout
fromEntityField
Another idea was to replace type of the internal map from
MutableMap<U, String>
toMutableMap<EntityField<T, Any>, String>
. It would solve the problem with the need for unsafe cast on write to map, but another compiler problem appears incollectedErrors
property. It expects keys of the resulting immutable map to be of typeU
, but I just redeclared types of the key for internal map to beEntityField<T, Any>
instead ofU
. How it can lead the to the problem? It was already mentioned that you can define type arguments for errors collector to beValidationErrorsCollector<Person, PersonFields<Number>>
, but it's not a legit combination. TheaddErrorIfStringFieldIsEmpty
putsPersonFields<String>
key to the internal map, but our incorrect types combinations leads to expectation fromcollectedErrors
to contain onlyPersonFields<Number>
.PersonFields<Number>
andPersonFields<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).