Improve type inference for types with common type parameters

I tried various ways of having a single hierarchy to use for “tagging” and using it to infer the member types of arbitrary “aggeragates” of data I’d like to be related by a type parameter, and ran into various inference limitations:

open class Wrapper<T>(
    val value: T,
)

class IntWrapper(
    value: Int,
) : Wrapper<Int>(value)

class AnotherWrapper<T>(
    val value: T,
)

class WrappersHolder<T>(
    val a: Wrapper<T>,
    val b: Wrapper<T>,
    val c: AnotherWrapper<T>,
    val d: T,
)

fun test() {
    val myWrappersHolder: WrappersHolder<*> =
        WrappersHolder(
            Wrapper(1),
            Wrapper(2),
            AnotherWrapper(3),
            4,
        )
    when (myWrappersHolder.a) {
        is IntWrapper -> {
            val inferredA = myWrappersHolder.a // Wrapper<out Any?>, doesn't infer properly
            val explicitA: Wrapper<Int> = myWrappersHolder.a // but this works
            val extractedB: Wrapper<Int> = myWrappersHolder.b // ERROR: initializer type mismatch, actual 'Wrapper<CapturedType(*)>'
            val extractedC: AnotherWrapper<Int> = myWrappersHolder.c // ERROR: initializer type mismatch, actual 'AnotherWrapper<CapturedType(*)>'
            val extractedD: Int = myWrappersHolder.d // ERROR: initializer type mismatch, actual 'CapturedType(*)'
            val inferredWrappersHolder: WrappersHolder<Int> = myWrappersHolder // ERROR: initializer type mismatch, actual 'WrappersHolder<*>'
        }
    }
}

2nd attempt:

interface TaggedObjectData<out T>

sealed interface TaggedObject<out T, out U : TaggedObjectData<T>> {
    val data: U
}

class IntTaggedObject<out U : TaggedObjectData<Int>>(
    override val data: U,
) : TaggedObject<Int, U>

class FieldUpdateData<out T>(
    val oldValue: T,
    val newValue: T,
) : TaggedObjectData<T>

// ERROR: type parameter 'T' of 'interface TaggedObjectData<out T>: Any' has inconsistent type bounds: R, kotlin.Any?
fun <R, U> U.unify(): FieldUpdateData<R> where U : TaggedObjectData<R>, U : FieldUpdateData<*> =
    this as FieldUpdateData<R>

fun test() {
    val fieldUpdate: TaggedObject<*, FieldUpdateData<*>> = IntTaggedObject(FieldUpdateData(1, 2))
    when (fieldUpdate) {
        is IntTaggedObject<*> -> {
            val e = fieldUpdate.data // TaggedObjectData<Int> & FieldUpdateData<*>
        }
    }
}

I attempted to manually unify the inferred intersection type with .unify().

inferredA is inferred correctly. Somehow, IntelliJ shows the type of this variable as Wrapper<out Any?>, but you can easily check we can e.g. pass it to a function accepting Wrapper<Int>, and inferredA.value is correctly inferred to Int.

As for your other examples, there are two problems. First, compiler isn’t smart enough to infer the type of one variable based on checking another one. Second, I don’t think your example is actually correct. What if we do:

        WrappersHolder(
            IntWrapper(1),
            Wrapper(2),
            AnotherWrapper(3),
            4,
        )

myWrappersHolder.a is IntWrapper, but other proeprties aren’t, so we can’t cast them automatically.

Also, I’m not entirely sure what is the problem you try to solve. Complex data structures of parameterized types is a common problem. One thing I don’t understand is why you check the type of one item in the structure and you try to infer the type of the whole data structure based on it.

edit:
Ok, I was a little wrong with my example, because Wrapper is invariant, so checking that myWrappersHolder.a is IntWrapper, probably guarantees that myWrappersHolder.b is Wrapper<Int>. But I feel such assumptions are rather error prone. If you ever decide to make Wrappercovariant (because it is), then as a result a and b could use entirely different T at runtime.

1 Like

You might want to look at the Subtyping Reconstruction proposal

1 Like