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:

  1. 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.
  2. Create an AnyList that uses the Any 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)
  1. [A: Stringable] is called a type constraint, and it indicates that whatever A the user chooses to pass to the hello method MUST have an implicit instance of Stringable[A] available. The syntax is actually a shorthand for something like this:
def hello[A](a: A)(implicit stringable: Stringable[A]): String =
  // ...
  1. With Stringable[A].makeString we are summoning the implicit instance of Stringable[A] which we know exists because of the type constraint on hello. Note that to use this syntax, we actually need to have a special method inside the companion object of Stringable:
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:

  1. 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 the List holds, it behaves the same way.
  2. 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.
  3. 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 our Writer does not need to know anything more about the generic type A than that it has the ability to be encoded as Json.