kotlingenericsextension-methodscovariancecovariant-return-types

Kotlin generic extension method with covariant return type


How do you write a generic extension method for a type hierarchy that has a method with covariant return type.

For example, the following types:

sealed interface MyInterface {
  fun map(): MyInterface

  class ImplA : MyInterface {
    override fun map(): ImplA = ImplA()
  }

  class ImplB : MyInterface {
    override fun map(): ImplB = ImplB()
  }
}
  

map() called on one of the subtypes return the concrete subtype. So I would expect to be able to write extension methods like this that take advantage of that:

fun <T : MyInterface> T.doThingWithMap(): T {
  return map()
}

val a: MyInterface.ImplA = MyInterface.ImplA().doThingWithMap()
val b: MyInterface.ImplB = MyInterface.ImplB().doThingWithMap()

But this doesn't compile with Type mismatch: inferred type is MyInterface but T was expected. How can I get this typesafe covariant behaviour?


Solution

  • Your code is type safe only because you introduced an informal convention where subtypes of MyInterface return the same types in their map function. Compiler doesn't understand this convention. Also, when we write the generic function, the compiler doesn't go through all known possible subtypes of MyInterface to check if the code is safe for all these specific subtypes. The only thing it sees is that the receiver is "some subtype of MyInterface", so from the compiler's perspective the map() returns simply MyInterface, not T.

    If you are in control of implementations of MyInterface and you know the convention is always met, you can simply cast the return value:

    fun <T : MyInterface> T.doThingWithMap(): T {
        @Suppress("UNCHECKED_CAST")
        return map() as T
    }
    

    This is an unchecked cast as due to type erasure, we don't know what's the T exactly and we can't check at runtime the type is correct.

    If you often use map() like this, you can create another extension function e.g. mapTyped and reuse it in other places.

    A better solution would be if we could specify that the map returns the same type as the receiver. Some languages support such feature, this is often called the "self" type and it could look like this:

    sealed interface MyInterface {
        fun map(): SELF // not a valid Kotlin code
    }
    

    That would fix the problem straight away. Unfortunately, Kotlin doesn't support this as of version 1.9.23 .

    One way to provide the "self" type is by making the type generic over itself. This is pretty verbose and almost a workaround, but it works as expected:

    sealed interface MyInterface<T : MyInterface<T>> {
        fun map(): T
    
        class ImplA : MyInterface<ImplA> {
            override fun map(): ImplA = ImplA()
        }
    
        class ImplB : MyInterface<ImplB> {
            override fun map(): ImplB = ImplB()
        }
    }
    
    fun <T : MyInterface<T>> T.doThingWithMap(): T {
        return map()
    }
    

    However, you should consider if it is worth the effort. Unchecked cast is a very simple solution and if your case is a sealed type that you control, then this is pretty safe to do.