Existential types

Versions: Scala 2.12.1

Scala has a rich types ecosystem with sometimes almost philosophical categories. One of such categories is the one of existential types.

This another post explaining Scala's features is devoted to existential types. It's composed of 2 parts. The first section defines this category of types while the second one shows how they can be used in different contexts.

Existential types definition

Simply speaking existential types lets us to "skip" given type. Let's take pretty easy example to understand the meaning of skipping here (function for only illustrative purpose):

def countElements(seq: Seq[_]): Int = seq.length

Do we should take care about the type held in the sequence ? Obviously not since we don't operate on the elements but only on the Seq property. And it's one of use cases when we can skip the type. To define existential types more properly we could tell that they provide a method to abstract over types by acknowledging the existence of some type with the permission to ignore it. It may be ignored because it's not important in given context. We can consider then that existential type contains some hidden parts not impacting its use.

An existential type in Scala can be written in 2 different manners:

type SomeArrayShorthand = Array[_]
type SomeArrayLongVersion = Array[T] forSome {type T}

Both versions can be enriched with type boundaries (more in the post about Scala type bounds):

type SomeArrayShorthand = Array[_ <: String]
type SomeArrayLongVersion = Array[T] forSome {type T <: String}

But why we need to deal with existential types ? It wouldn't be shorter to work with Any type ? Unfortunately not because it's less flexible. Let's take again our countElements method but this time applied on Sets:

def countSets(set: Set[Any]): Int = set.size

countSets(new HashSet[Int]())

It won't compile because of type mismatch error:

Error:(20, 15) type mismatch;
 found   : scala.collection.immutable.HashSet[Int]
 required: Set[Any]
Note: Int <: Any, but trait Set is invariant in type A.
You may wish to investigate a wildcard type such as `_ <: Any`. (SLS 3.2.10)
    countSets(new HashSet[Int]())

The same method with Set[_] in signature works better.

Existential types can also be used to establish a dependency between the types. As shown in the example of SomeArrayShorthand, this alias is independent on the type T. It means that we can write val numbers: SomeArrayShorthand = new Array[Int](3). If it wouldn't be the case and the alias was defined as type SomeArrayShorthand[T] = Array[T], we should write val numbers: SomeArrayShorthand[Int] = new Array[Int](3).

Existential types example

After this short explanation, let's pass to the use cases presented. Among the possible uses of the existential types we can distinguish a type erasure (e.g. in pattern matching), quoted above type-unaware methods or aliases:

behavior of "existential type"

it should "erase the type in pattern matching and go to the bad case" in {
  def matchSequence(sequence: Seq[_]): String = {
    sequence match {
      case _: Seq[Int] => "Seq.int"
      case _: Seq[String] => "Seq.string"
      case _ => "default"
    }
  }

  val incorrectlyMatchedString = matchSequence(Seq(10.0d, 12.0d))

  // Because of the type erasure at compile time, the Seq types are ignored
  // The type erasure can be seen with the compilation warnings:
  // Warning:(15, 17) non-variable type argument Int in type pattern Seq[Int] (the underlying of Seq[Int]) is unchecked since it is eliminated by erasure
  // case _: Seq[Int] => "Seq.int"
  //  Warning:(16, 17) non-variable type argument String in type pattern Seq[String] (the underlying of Seq[String]) is unchecked since it is eliminated by erasure
  // case _: Seq[String] => "Seq.string"
  //  Warning:(16, 32) unreachable code
  // case _: Seq[String] => "Seq.string"
  incorrectlyMatchedString shouldEqual "Seq.int"
}

it should "not erase the types in pattern matching" in {
  def matchSequence(seq: Seq[_]): String = {
    seq match {
      case _:Seq[_] => "Any Sequence"
      case _ => "default"
    }
  }

  val matchedSequence = matchSequence(Seq(1, 2, 3))

  matchedSequence shouldEqual "Any Sequence"
}

it should "be correctly used in the function ignoring underlying type" in {
  def countElements(seq: Seq[_]): Int = seq.length

  val intsCounter = countElements(Seq(1, 2, 3))
  val stringCounter = countElements(Seq("a", "b", "c"))

  intsCounter shouldEqual stringCounter
  stringCounter shouldEqual 3
}

it should "document given type as type-independent" in {
  type SomeArrayShorthand = Array[_]
  def isEmpty(array: SomeArrayShorthand): Boolean = array.isEmpty

  val intArray: SomeArrayShorthand = new Array[Int](2)
  val emptyArrayFlag = isEmpty(intArray)

  emptyArrayFlag shouldBe false
}

This post presented the existential types in Scala. The first part defined them as the types that exist but may remain undefined. It's in fact because often we don't need to know them to make given operation. One of possible use cases can be the methods working on the properties of containers. Since to know their length, emptiness or other attributes we don't need to know exactly what type is stored within. We should also keep in mind that the existential types works in the pattern matching for these containers. As shown in one of tests in the 2nd section, the pattern matching on exact types simply doesn't work.