scaladependent-typedottytype-projectionscala-3

Dotty cannot infer result type of generic Scala function taking type parameter trait with abstract type


A simple value hierarchy

Imagine this simple trait Value where every implementing class has a value of some type T.

trait Value {
  type T
  def value: T
}

We have two different implementing classes representing Int and String values respectively.

case class IntValue(override val value: Int) extends Value {
  override type T = Int
}

case class StringValue(override val value: String) extends Value {
  override type T = String
}

Type safe selection of Values

If we have a List of values we would like to have a type safe way of selecting all values of a specific type. Class Values and its companion object help us doing that:

object Values {
  private type GroupedValues = Map[ClassTag[_ <: Value], List[Value]]

  def apply(values: List[Value]): Values = {
    val groupedValues: GroupedValues = values.groupBy(value => ClassTag(value.getClass))
    new Values(groupedValues)
  }
}

class Values private (groupedValues: Values.GroupedValues) {
  // Get a List of all values of type V.
  def getValues[V <: Value : ClassTag] = {
    val classTag = implicitly[ClassTag[V]]
    groupedValues.get(classTag).map(_.asInstanceOf[List[V]]).getOrElse(Nil)
  }

  def getValue[V <: Value : ClassTag] = {
    getValues.head
  }

  def getValueOption[V <: Value : ClassTag] = {
    getValues.headOption
  }

  def getValueInner[V <: Value : ClassTag] = {
    getValues.head.value
  }
}

All this works fine in both Scala 2.13 and Dotty 0.20.0-RC1 so having a list of mixed values…

val valueList = List(IntValue(1), StringValue("hello"))
val values = Values(valueList)

…we can select elements and get them returned as the correct type – all checked at compile-time:

val ints: List[IntValue] = values.getValues[IntValue]
val strings: List[StringValue] = values.getValues[StringValue]

val int: IntValue = values.getValue[IntValue]
val string: StringValue = values.getValue[StringValue]

val intOption: Option[IntValue] = values.getValueOption[IntValue]
val stringOption: Option[StringValue] = values.getValueOption[StringValue]

val i: Int = values.getValueInner[IntValue]
val s: String = values.getValueInner[StringValue]

Selecting a value as Option[T] fails in Dotty

However, if we add this function to select values as their T type (i.e. Int and String) and get it returned as an Option

class Values ... {
  ...
  def getValueInnerOption[V <: Value : ClassTag] = {
    getValues.headOption.map(_.value)
  }
}

…then things work fine in Scala 2.13:

val iOption: Option[Int] = values.getValueInnerOption[IntValue]
val sOption: Option[String] = values.getValueInnerOption[StringValue]

But in Dotty 0.20.0-RC1 this does not compile:

-- [E007] Type Mismatch Error: getValue.scala:74:29 
74 |  val iOption: Option[Int] = values.getValueInnerOption[IntValue]
   |                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |                             Found:    Option[Any]
   |                             Required: Option[Int]
-- [E007] Type Mismatch Error: getValue.scala:75:32 
75 |  val sOption: Option[String] = values.getValueInnerOption[StringValue]
   |                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |                                Found:    Option[Any]
   |                                Required: Option[String]

We can fix the problem by adding a type parameter to getValueInnerOption that ties the return type and the abstract type T together and allows us to specify the return type.

def getValueInnerOption[V <: Value {type T = U} : ClassTag, U]: Option[U] = {
  getValues.headOption.map(_.value)
}

Unfortunately, this means that we will have to add the actual type of T (i.e Int or String) at the call site which is a pity because it is just boilerplate.

val iOption: Option[Int] = values.getValueInnerOption[IntValue, Int]
val sOption: Option[String] = values.getValueInnerOption[StringValue, String]

A bug in Dotty or what to do?

It seems that Dotty already knows what the upper bound of T is but cannot propagate that knowledge to the result type of the function. This can be seen if trying to ask for a String from an IntValue:

-- [E057] Type Mismatch Error: getValue.scala:75:39 
75 |  val wtf = values.getValueInnerOption[IntValue, String]
   |                                       ^
   |Type argument IntValue does not conform to upper bound Value{T = String} 

so is the original code (without type parameter U) something that can be expected to work in the final Scala 3.0 or does it need to be written in a different way?


Solution

  • In Dotty try match types as a replacement for type projections

    type InnerType[V <: Value] = V match {
      case IntValue    => Int
      case StringValue => String
    }
    
    trait Value {
      type This >: this.type <: Value
      type T = InnerType[This]
      def value: T
    }
    
    case class IntValue(override val value: Int) extends Value {
      override type This = IntValue
    }
    
    case class StringValue(override val value: String) extends Value {
      override type This = StringValue
    }
    
    def getValueInner[V <: Value { type This = V } : ClassTag]: InnerType[V] = {
      getValues.head.value
    }
    
    def getValueInnerOption[V <: Value { type This = V } : ClassTag]: Option[InnerType[V]] = {
      getValues.headOption.map(_.value)
    }