Generic Types
Generic types in Scala allow for abstractions that cover different, potentially unrelated, types. A good example is the List
type in Scala. If it weren't for generics, we'd have two main options for implementing lists:
- Create a different
List
type for each type of elements that we may need to deal with.StringList
,IntList
, etc. The downside here is that we would end up repeating our logic many times just to be able to deal with different inner types. - Create an
AnyList
that uses theAny
type to hold any type of elements. The downside to this approach is that we lose valuable information about what type of elements exist inside our list. We would then need to utilize unsafe casting to get that information back.
The real takeaway here is that a List
is a List
, regardless of what type of elements it holds. As such, we can abstract over the type of its elements, and maintain the functionality we need to have for a List
. This is where generics come in, and our list is actually a List[A]
where A
can be any type like String
or Int
giving us List[String]
or List[Int]
respectively.
Here is a simplified look at a List
implementation using a generic type.
trait OurList[A]() {
def headOption: Option[A]
def tail: OurList[A]
}
object OurList {
case object Nil extends OurList[Nothing] {
val headOption: Option[Nothing] = None
val tail: OurList[Nothing] = this
}
final case class Cons[A](head: A, val tail: OurList[A]) extends OurList[A] {
val headOption: Option[A] = Some(head)
}
}
Notice that all the logic for OurList
is unaware of what type A
actually is. This works since it is only operating on the List
itself and never on the elements directly. If we do want to provide a way to operate on the elements themselves, we can do so in a few ways.
Higher-Order Functions
The first of these ways is using higher-order functions, or functions that take one or more functions as parameters. An example using this approach is the implementation of List's map
function.
trait OurList[A] {
// ...
// note this implementation is not stack safe and is
// just for illustration purposes.
def map[B](f: A => B): OurList[B] = this match {
case Nil => Nil
case Cons(h, t) => Cons(f(h), tail.map(f))
}
}
Here we are allowing all elements of OurList
to be modified by some function f
that can transform the elements of type A
into elements of type B
. We don't know what the actual type of these elements are, and we don't need to. Instead, we just provide a way for the elements to be acted on with a function. When actually using OurList
in context, such as in an application, the type will be known. For example:
val list: OurList[Int] = // ...
val strList: OurList[String] = list.map(_.toString)
Type Classes
Another approach we can take for acting on elements of a generic type is using the type class pattern. Before we get to an example of this, let's cover what a type class is.
A type class is a parameterized type (parameterized types are types that take in generic types as parameters) that defines a certain behavior or set of behaviors for the type(s) which it parameterizes. For example, we can define a type class which provides the behavior of "stringifying" a value of type A
like so:
trait Stringable[A] {
def makeString(a: A): String
}
Here our type class is defined as a trait which takes a single type parameter, A
, and defines a single method, "makeString" which takes a value of type A
and turns it into a string.
We can implement the Stringable
type class for Integer
types as follows:
implicit val intStringable: Stringable[Int] = new Stringable[Int] {
override def makeString(i: Int): String = i.toString
}
Note that we have made this value implicit. This is so that we can take advantage of this type implicitly later on when we are looking to summon this instance of Stringable
for integers.
Now that we have defined an example type class we will implement a method, hello
which will show how a type class is used.
def hello[A: Stringable](a: A): String =
"Hello, " + Stringable[A].makeString(a)
[A: Stringable]
is called a type constraint, and it indicates that whateverA
the user chooses to pass to thehello
method MUST have an implicit instance ofStringable[A]
available. The syntax is actually a shorthand for something like this:
def hello[A](a: A)(implicit stringable: Stringable[A]): String =
// ...
- With
Stringable[A].makeString
we are summoning the implicit instance ofStringable[A]
which we know exists because of the type constraint onhello
. Note that to use this syntax, we actually need to have a special method inside the companion object ofStringable
:
object Stringable {
def apply[A](implicit s: Stringable[A]): s.type = s
}
If we don't have a method like this in the companion object, then we can use implicitly[Stringable[A]].makeString
instead of Stringable[A].makeString
.
With a simple example in mind, let's now look at a more realistic example that shows using generic types and type classes.
Realistic Example
Here we will create a simple JSON "library" for encoding case classes as a JSON ADT (algebraic data type).
First, let's define a simple JSON ADT:
sealed trait Json
object Json {
final case class JObject(values: Map[String, Json]) extends Json
final case class JList(values: List[Json]) extends Json
final case class JInt(value: Int) extends Json
final case class JString(value: String) extends Json
// we aren't going to implement all possible cases for brevity
}
Now that we have an ADT, let's define a type class which has the behavior of encoding some type A
to our Json
ADT.
trait Encoder[A] {
def encode(a: A): Json
}
Now we will implement some helpful implicit instances in the companion object of Encoder
for dealing with the encoding of common Scala types like Int
, String
, Map
and List
. We put these in the companion object such that they will be found by Scala's implicit resolution when summoning an Encoder
for one of these types.
object Encoder {
// to make summoning simpler, as discussed above
implicit def apply[A](implicit a: Encoder[A]): a.type = a
implicit val encInt: Encoder[Int] = Json.JInt(_)
implicit val encStr: Encoder[String] = Json.JString(_)
implicit def encMap[V: Encoder]: Encoder[Map[String, V]] = new Encoder[Map[String, V]] {
def encode(values: Map[String, V]): Json =
Json.JObject(values.map { case (k, v) => k -> Encoder[V].encode(v) }.toMap)
}
implicit def encList[A: Encoder]: Encoder[List[A]] = new Encoder[List[A]] {
def encode(values: List[A]): Json = Json.JList(values.map(Encoder[A].encode))
}
}
Note that the encoders for Map
and List
have to be implicit methods rather than simple values so they can take in type parameters. This means that we can now encode lists or maps of any type, as long as the type itself is encodable.
Now let's implement an Encoder
for a custom type, Person
.
final case class Person(id: Int, name: String)
object Person {
implicit val encoder: Encoder[Person] = new Encoder[Person] {
def encode(p: Person): Json = {
Json.JObject(Map(
"id" -> Encoder[Int].encode(p.id),
"name" -> Encoder[String].encode(p.name)
))
}
}
}
Now that we have an encoder for Person
, let's implement one for a list of Person
called People
.
final case class People(people: List[Person])
object People {
implicit val encoder: Encoder[People] = new Encoder[People] {
def encode(p: People): Json = {
Json.JObject(
"people" -> Encoder[List[Person]].encode(p.people)
)
}
}
}
Here we can summon an Encoder[List[Person]]
although we never explicitly defined one. We can do this because we defined an Encoder
for a List
of any type A
as long as the A
itself has an instance of Encoder
. In this case, we have a List[Person]
and we have an Encoder
for Person
which the List encoder will use in its implementation.
Now let's create a Writer
that has the ability to write any type A
with an Encoder
instance to a string.
object Writer {
def write[A: Encoder](a: A): String = {
val json = Encoder[A].encode(a)
// write the json to string
// ...
}
}
Now the writer here only needs to know how to take the Json
ADT and turn it into a String JSON representation. It doesn't need to know anything further about the generic type A
than that it has the ability to be encoded as Json
using an Encoder
.
Conclusion
There is so much to be learned about generic types, type classes, and related topics that we can't possibly cover them all here. However, this article covers the main foundational pieces for using generic types effectively.
In summary:
- Generic types are used to abstract over different types when we want the same logic to apply regardless of the actual type we are working with. An example of this is a
List
since regardless of what theList
holds, it behaves the same way. - We can pass in functions that operate on the generic types which allows doing helpful things with the type while still keeping the implementation unaware of what the actual type is.
- Sometimes we want the implementation to become aware not of the type itself, but rather a set of behaviors that the type has. We use type classes to express this as we did in our
Json
Encoder
example above where ourWriter
does not need to know anything more about the generic typeA
than that it has the ability to be encoded asJson
.