Existential types in Scala
scalatypesexistentialtypeclasstypelevel
1902 Words … ⏲ Reading Time: 8 Minutes, 38 Seconds
2017-04-07 00:00 +0000
Type variables are a powerful piece of type-level programming. They can appear in a variety of forms and factors; phantoms and existential types being two cases. In this article we will be exploring existential types via a specific use case you may encounter in production code.
But before we can talk about existential types, we must talk about type variables in general.
Type Variables
Normally, this is the form in which we see type variables:
type F[A] = SomeClass[A]
A
is said to be a “type variable”. Note that A
appears on both sides of the
equation: on F
and on SomeClass
. This means that F
is fully dependent on
the type of the data for SomeClass
. For example, all of these are valid:
SomeClass("hello"): F[String]
SomeClass(1: Int): F[Int]
SomeClass(true): F[Boolean]
While the following isn’t:
SomeClass("hello"): F[Int] // does not compile
Now let’s expand this into something more concrete (with traits and classes):
sealed trait F[A]
final case class SomeClass[A](a: A) extends F[A]
SomeClass("hello"): F[String]
SomeClass(1: Int): F[Int]
SomeClass(true): F[Boolean]
This is pretty straight forward and you probably aren’t impressed, but it is important to see the basic example since existential types (and phantom ones) are a variation on it.
Existential Types
Existential types are like the normal type variables we saw above, except the variable only shows up on the right:
type F[A] = SomeClass[A] // `A` appears on the left and right side, common case
type F = SomeClass[A] forSome { type A } // `A` appears only on the right, existential case
Now A
only appears on the right side, which means that the final type, F
,
will not change regardless of what A
is. For example:
SomeClass("hello"): F
SomeClass(1: Int): F
SomeClass(user): F
Side note: if A
were to only appear on the left side, then A
would be a
Phantom type. We aren’t covering those in this article though, so let’s ignore
them.
Like before, let’s now expand it. Beware, this expansion isn’t as clean as the previous one:
sealed trait Existential {
type Inner
val value: Inner
}
final case class MkEx[A](value: A) extends Existential { type Inner = A }
Here we’re using path dependent types in order to create a better interface for
our Existential
trait. We could just have an empty trait but that would mean
that we would need to case match MkEx
whenever we wanted to access its
fields. However, the concept still holds and shares the properties we described
above:
MkEx("hello"): Existential
MkEx(1: Int): Existential
MkEx(user): Existential
We could think of MkEx
as a type eraser: it doesn’t matter what type of data
we choose to put into MkEx
, it will erase the type and always return
Existential
.
Time for a game, let’s say you have some function that, when called,
returns an Existential
. What is the type of the inner value
field?
val ex: Existential = getSomeExistential(...)
ex.value: ???
The answer is ex.Inner
of course. But what is ex.Inner
? The answer to
that is that we can’t know. The original type defined in MkEx
has been erased
from compilation, never to be seen again. Now that’s not to say that we have
lost all information about it. We know that it exists (we have a reference to
it via ex.Inner
), hence why it is called “existential”, but sadly that’s
pretty much all the information we know about it.
This means that, in its current form, Existential
and MkEx
are useless. We
can’t pass ex.value
anywhere expect where ex.Inner
is required, but even
ex.Inner
is pretty bare bones with no properties for us to use: so once we
accept it in a function, what do we do with it? Well nothing right now, but we
could add restrictions to the type, which in terms would allow us to do
something with it without knowing what it is.
And here is where Existential types shine: they have the interesting property of unifying different types into a single one with shared restrictions. These restrictions could be anything: from upper bounds to type classes or even a combination. To show this we will be creating a type-safe wrapper around an unsafe Java library.
Let’s say you have a Java library that contains the following signature:
def bind(objs: Object*): Statement
This signature isn’t special, many SQL Java libraries have a method similar to
it. Normally you would define some string with many question marks (?) followed
by a call to a bind
method that would bind the passed objects to the question
marks (hopefully sanitizing the input in the process). Like so:
SELECT * FROM table WHERE a = ? AND b = ?;
The problem though is that Object
is a very general thing. Obviously, if we
pass bind(user)
, where user
is some class we defined, that wouldn’t work.
However, it would compile and crash at runtime. Additionally, a lot of these
Java libraries are, well, for Java, so certain Scala types won’t work either
(eg: BigInt, Int, List). In fact, you could think of Object
as an
existential type.
SomeClass(1): Object
SomeOtherClass("hello"): Object
Boolean.box(true): Object
The problem is that it is too wide, it encompasses ALL types. This all means
that we need to somehow “restrict” the number of types that are allowed to go
into bind
. This new bind, we shall call it safeBind
will:
- Convert types to their Java counterpart (BigInt -> Long, Int -> Integer)
- Fail to compile any nonsensical types (User, Profile)
We aren’t going to be making the length of parameters passed safe (that would require a lot more than existentials). Nor are we checking that the types passed match the expected types in the SQL query.
safeBind
will be defined something like so:
def safeBind(columns: AnyAllowedType*): Statement =
bind(columns.map(_.toObject):_*)
Note that safeBind
doesn’t look that much different that bind
except for
AnyAllowedType
. But before we can talk about AnyAllowedType
, we need to
define what types are allowed. For this, we will use a typeclass, called
AllowedType
, since they lend themselves well for defining a set of types
unrelated types that have a specific functionality.
This typeclass is defined as:
sealed trait AllowedType[A] {
/**
* The Java type we are converting to.
*
* Note the restrictions:
*
* * `:> Null` means that we can turn the type into `null`.
* This is needed since many Java SQL libraries interpret NULL as `null`
* * `<: AnyRef` just means "this is an Object". ie: not an AnyVal.
*/
type JavaType :> Null <: AnyRef
/**
* Function that converts `A` (eg: Int) to the JavaType (eg: Integer)
*/
def toJavaType(a: A): JavaType
/**
* Same as above, but upcasts to Object (which is what `bind` expects)
*/
def toObject(a: A): Object = toJavaType(a)
}
object AllowedType {
def apply[A](implicit ev: AllowedType[A]) = ev
def instance[A, J >: Null <: AnyRef](f: A => J): AllowedType[A] =
new AllowedType[A] {
type JavaType = J
def toJavaType(a: A) = f(a)
}
}
The stuff in the companion object are just helper functions we will use later. Now let’s define some types that will be allowed through:
object AllowedType {
implicit val intInstance: AllowedType[Int] = instance(Int.box(_))
implicit val strInstance: AllowedType[String] = instance(identity)
implicit val boolInstance: AllowedType[Boolean] = instance(Boolean.box(_))
implicit val instantInst: AllowedType[Instant] = instance(Date.from(_))
// For Option, we turn `None` into `null`; this is why we needed that `:> Null`
// restriction
implicit def optionInst[A](implicit ev: AllowedType[A]): AllowedType[Option[A]] =
instance[Option[A], ev.JavaType](s => s.map(ev.toJavaType(_)).orNull)
}
This gives us our filter and converter: we can only call
AllowedType[A].toObject(a)
if A
implements our typeclass.
Great, now we need to define AnyAllowedType
. As we saw above, we want
AnyAllowedType
to behave somewhat like Object
, but for our small set of
types. We can achieve this using an Existential type but with an evidence for
our AllowedType
typeclass:
sealed trait AnyAllowedType {
type A
val value: A
val evidence: AllowedType[A]
}
final case class MkAnyAllowedType[A0](value: A0)(implicit val evidence: AllowedType[A0])
extends AnyAllowedType { type A = A0 }
This existential is similar to the Existential
we defined above, except that
we are now asking for an evidence for AllowedType[A]
and capturing it as part
of the trait. This means that, unlike before, we can’t pass any random type
anymore:
MkAnyAllowedType("Hello"): AnyAllowedType
MkAnyAllowedType(1: Int): AnyAllowedType
MkAnyAllowedType(Instant.now()): AnyAllowedType
MkAnyAllowedType(user): AnyAllowedType // won't compile since we don't have an AllowedType instance for User
Great! Now we have our AnyAllowedType
defined. Let’s see it in action by
defining safeBind
.
def safeBind(any: AnyAllowedType*): Statement =
bind(any.map(ex => ex.evidence.toObject(ex.value)):_*)
Note that we still don’t know what the type of ex.value
is (just like before).
However, we do know that it is the same type as the evidence ex.evidence
.
That means that all functions, that are part of the evidence (ie: typeclass),
match the type of ex.value
! So our knowledge of ex.value
has expanded from,
“all we know is that it exists” to “we know it exists AND that it implements
the typeclass AllowedType”.
Finally, when we go to use safeBind
, we do as such:
safeBind(MkAnyAllowedType(1), MkAnyAllowedType("Hello"), MkAnyAllowedType(Instant.now()))
And it works! But the following will not:
safeBind(MkAnyAllowedType(1), MkAnyAllowedType(user)) // Does not compile, no instance of AllowedType for User
We are essentially done now, we just need to wrap our values in
MkAnyAllowedType
and the compiler will do the rest (or yell).
However, there are some extra tweaks we can make to make our interface better.
Making an AllowedType instance for AnyAllowedType
You many have noticed that it is awkward to call functions in ex.evidence
.
ex.evidence.toObject(ex.value)
We can make this better by creating an instance for AllowedType
:
object AnyAllowedType {
implicit val anyAllowedInst: AllowedType[AnyAllowedType] =
AllowedType.instance(ex => ex.evidence(ev.value))
}
// Now we can simply do
val ex: AnyAllowedType = …
AllowedType[AnyAllowedType].toObject(ex)
Using implicit conversions to avoid wrapping
Having to do this manual wrapping can become old fast:
safeBind(MkAnyAllowedType(1), MkAnyAllowedType("Hello"), …)
We can actually avoid it by using an implicit conversion:
object AnyAllowedType {
implicit def anyAllowedToAny[A: AllowedType](a: A): AnyAllowedType =
AnyAllowedType(a)
}
Now we can simply call:
safeBind(1, "Hello", Instant.now(), true, …)
And passing user
will fail, like before.
Generalize AnyAllowedType
When we created AnyAllowedType
, we made it for the typeclass AllowedType
.
Does this mean that we need to make a new AnyX
for every X
typeclass we
have? Nope, we do not. We can generalize AnyAllowedType
to work for ANY
typeclass. This would require a simple modification:
sealed trait TCBox[TC[_]] {
type A
val value: A
val evidence: TC[A]
}
final case class MkTCBox[TC[_], B](value: A)(implicit val evidence: TC[A])
extends TCBox[TC] { type A = B }
Now, instead of hard-coding it to AllowedType
, we take the typeclass as a
type TC[_]
. We still take a TC[A]
implicitly in MkTCBox
along with the
value. Note though that TC[_]
isn’t existential, nor phantom, it is just a
common type variable. A TCBox[TC]
is, essentially, a “TypeClass Box” for the
typeclass “TC”.
Our implicit conversion can also be translated:
object TCBox {
implicit def anyTCBox[TC[_], A: TC](a: A): TCBox[TC] = MkTCBox(a)
}
Finally, a simple type alias type AnyAllowedType = TCBox[AllowedType]
would
make everything we have written before keep working.
Conclusion
Existential types don’t seem that useful when first encountered, but they can be quite powerful when mixed with the correct restrictions. The use case we presented is one of the simpler uses but they can be as complex or as simple as your use case requires them to be.
PS: you can find all the code we just wrote for TCBox here: https://gist.github.com/pjrt/269ddd1d8036374c648dbf6d52fb388f