Scala 3: Generic type parameters and variance explained (type system)

Note: This tutorial is an excerpt from the Scala Cookbook, 2nd Edition.

As you can tell from one look at the Scaladoc for the collections classes, Scala has a powerful type system. However, unless you’re the creator of a library, you can go a long way in Scala without having to go too far down into the depths of Scala types. But once you start creating libraries for other users, you will need to learn them. This chapter provides recipes for the most common type-related problems you’ll encounter.

Scala’s type system uses a set of symbols to express different generic type concepts, including the concepts of bounds, variance, and constraints.

Note: In this blog post, only generic type parameters and variance are discussed. See the Scala Cookbook for the complete discussion.

Generic type parameters

When you first begin writing Scala code you’ll use types like Int, String, and custom types you create, like Person, Employee, Pizza, and so on. Then you’ll create traits, classes, and methods that use those types. Here’s an example of a method that uses the Int type, as well as a Seq[Int]:

// ignore possible errors that can occur
def first(xs: Seq[Int]): Int = xs(0)

Seq[Int] is a situation where one type is a container of another type. List[String] and Option[Int] are also examples of types that contain another type.

As you become more experienced working with types, when you look at the first method you’ll see that its return type has no dependency at all on what’s inside the Seq container. The Seq can contain types like Int, String, Fish, Bird, and so on, and the body of the method would never change. As a result, you can rewrite that method using a generic type, like this:

def first[A](xs: Seq[A]): A = xs(0)
         ___         _    _

The underlined portions of the code show how a generic type is specified. Reading from right to left in the code:

  • As noted, the type is not referenced in the method body, there’s only xs(0)

  • A is used as the method return type, instead of Int

  • A is used inside the Seq, instead of Int

  • A is specified in brackets, just prior to the method declaration

Specifying the generic type in brackets is a way to tell the compiler and other readers of the code that you’re about to see a generic type used in the remainder of the method signature.

Writing generic code like this makes your code more useful to more people. Instead of just working for a Seq[Int], the method now works for a Seq[Fish], Seq[Bird], and in general — hence the word “generic” — a Seq of any type.

By convention, when you declare generic types in Scala, the first generic type that’s specified uses the letter A, the second generic type is B, and so on. For instance, if Scala didn’t include tuples and you wanted to declare your own class that can contain two different types, you’d declare it like this:

class Pair[A,B](val a: A, val b: B)

Here are a few examples of how to use that class:

Pair(1, 2)       // A and B are both Int
Pair(1, "1")     // A is Int, B is String
Pair("1", 2.2)   // A is String, B is Double

In the first example, A and B happen to have the same type, and in the last two examples, A and B have different types.

Finally, to round out our first generic type examples, let’s create a trait that uses generic parameters, and then a class that implements that trait. First, let’s create two little classes that the example will need, along with our previous Pair class:

class Cat
class Dog
class Pair[A,B](val a: A, val b: B)

Given that as background, this is how you create a parameterized trait with two generic type parameters:

trait Foo[A,B]:
    def pair(): Pair[A, B]

Notice that you declare the types you need after the trait name, then reference those types inside the trait.

Next, here’s a class that implements that trait for dogs and cats:

class Bar extends Foo[Cat, Dog]:
    def pair(): Pair[Cat, Dog] = Pair(Cat(), Dog())

This first line of code declares that Bar works for Cat and Dog types, with Cat being a specific replacement for A, and Dog being a replacement for B:

class Bar extends Foo[Cat, Dog]:

If you want to create another class that extends Foo and works with a String and Int, you’d write it like this:

class Baz extends Foo[String, Int]:
    def pair(): Pair[String, Int] = Pair("1", 2)

These examples demonstrate how generic type parameters are used in different situations.

As you work more with generic types, you’ll find that you want to define certain expectations and limits on those types. To handle those situations you’ll use bounds, variance, and type constraints, which are discussed next.

Again, only variance is covered in this blog post.

Variance

As its name implies, variance is a concept that’s related to how generic type parameters can vary when subclasses of your type are created. Scala uses what’s known as declaration-site variance, which means that you — the library creator — declare variance annotations on your generic type parameters when you create new types like traits and classes. This is the opposite of Java, which uses use-site variance, meaning that clients of your library are responsible for understanding these annotations.

Because we use collections like List and ArrayBuffer all the time, I find that it’s easiest to demonstrate variance when creating new types like those. So as an example, I’ll create a new type named Container that contains one element. When I define Container, variance has to do with whether I define its generic type parameter A as A, +A, or -A:

class Container[A](a: A) ...    // invariant
class Container[+A](a: A) ...   // covariant
class Container[-A](a: A) ...   // contravariant

How I declare A now affects how Container instances can be used later. For example, variance comes into play in discussions like this:

  • When I define a new Container type using one of those annotations,

  • If I also define a class Dog that’s a subtype of Animal,

  • Is Container[Dog] a subtype of Container[Animal]?

In concrete terms, what this means is that if you have a method like this that’s defined to accept a parameter of type Container[Animal]:

def foo(c: Container[Animal]) = ???

can you pass a Container[Dog] into foo?

Two ways to simplify variance

Variance can take a few steps to explain because you have to talk about both (a) how the generic parameter is initially declared as well as (b) how instances of your container are later used, but I’ve found that there are two ways to simplify the topic.

1) If everything is immutable

The first way to simplify variance is to know that if everything in Scala was immutable, there would be little need for variance. Specifically, in a totally immutable world where all fields are val and all collections are immutable (like List), if Dog is a subclass of Animal, Container[Dog] will definitely be a subclass of Container[Animal].

Note: As you’ll see in the following discussion, this means that the need for invariance goes away.

This is demonstrated in the following code. First I create an Animal trait and then a Dog case class that extends Animal:

sealed trait Animal:
    def name: String

case class Dog(name: String) extends Animal

Now I define my Container class, declaring its generic type parameter as +A, making it covariant. While that’s a fancy mathematical term, it just means that when a method is declared to take a Container[Animal], you can pass it a Container[Dog]. Because the type is covariant, it’s flexible, and is allowed to vary in this direction, i.e., allowed to accept a sub-type:

class Container[+A](a: A):
    def get: A = a

Then I create an instance of a Dog as well as a Container[Dog], and then verify that the get method in the Container works as desired:

val d = Dog("Fido")
val h = Container[Dog](d)
h.get   // Dog(Fido)

To finish the example, I define a method that takes a Container[Animal] parameter:

def printName(c: Container[Animal]) = println(c.get.name)

Finally, I pass that method a Container[Dog] variable, and the method works as desired:

printName(h)   // "Fido"

To recap, all of that code works because everything is immutable, and I define Container with the generic parameter +A.

Note that if I defined that parameter as just A or as -A, that code would not compile. (For more information on this, read on.)

2) Variance is related to the type’s “in” and “out” positions

There’s also a second way to simplify the concept of variance, which I summarize in the following three paragraphs:

(a) As you just saw, the get method in the Container class only uses the type A as its return type. This is no coincidence: Whenever you declare a parameter as +A, it can only ever be used as the return type of Container methods. You can think of this as being an out position, and your container is said to be a producer: methods like get produce the type A. In addition to the Container[+A] class just shown, other “producer” examples are the Scala List[+A] and Vector[+A] classes. With these classes, once an instance of them is created, you can never add more A values to them. Instead, they’re immutable and read-only, and you can only access their A values with the methods that are built into them. You can think of List and Vector as being producers of elements of type A (and derivations of A).

(b) Conversely, if the generic type parameter you specify is only used as input parameters to methods in your container, declare the parameter to be contravariant using -A. This declaration tells the compiler that values of type A will only ever be passed into your container’s methods — the “in” position — and will never be returned by them. Therefore, your container is said to be a consumer. (Note that this situation is rare compared to the other two possibilities, but in the producer/consumer discussion, it’s easiest to mention it second.)

(c) Finally, if the generic parameter is used in both the method return type position and as a method parameter inside your container, define the type to be invariant by declaring it with the symbol A. Using this type declares that your class is both a producer and a consumer of the A type, and as a side-effect of this flexibility, the type is invariant — meaning that it cannot vary. When a method is declared to accept a Container[Dog], it can only accept a Container[Dog]. This type is used when defining mutable containers, such as the ArrayBuffer[A] class, to which you can add new elements, edit elements, and access elements.

Here are examples of these three producer/consumer situations.

In the first case, when a generic type is only used as a method return type, the container is a producer, and you mark the type as covariant with +A:

// covariant: A is only ever used in the “out” position.
trait Producer[+A]:
    def get: A

Note that for this use case, the C# and Kotlin languages — which also use declaration-site variance — use the keyword out when defining A. If Scala used out instead of +, the code would look like this:

trait Producer[out A]:   // if Scala used 'out' instead
    def get: A

For the second situation, if the generic type parameter is only used as the input parameter to your container methods, the container can be thought of as a consumer. Mark the generic type as contravariant using -A:

// contravariant: A is only ever used in the “in” position.
trait Consumer[-A]:
    def consume(a: A): Unit

In this case, C# and Kotlin use the keyword in to indicate that A is only used as a method input parameter (the “in” position). If Scala had that keyword, your code would look like this:

trait Consumer[in A]:   // if Scala used 'in' instead
    def consume(a: A): Unit

Finally, when a generic type parameter is used both as method input parameters and method return parameters, it’s considered invariant — not allowed to vary — and designated as A:

// invariant: A is used in the “in” and “out” positions
trait ProducerConsumer[A]:
    def consume(a: A): Unit
    def produce(): A

Note: Two tables that are shown in the book have been omitted from this post.

It’s actually hard to find a consistent definition of these variance terms, but this Microsoft C# page provides good definitions, which I’ll re-phrase slightly here:

Covariance (+A in Scala)

Lets you use a “more derived” type than what is specified. This means that you can use a subtype where a parent type is declared. In my example this means that you can pass a Container[Dog] where a Container[Animal] method parameter is declared.

Contravariance (-A)

Essentially the opposite of covariance, you can use a more generic (less derived) type than what is specified. For instance, you can use a Container[Animal] where a Container[Dog] is specified.

Invariance (A)

This means that the type can’t vary — you can only use the type that’s specified. If a method requires a parameter of the type Container[Dog], you can only give it a Container[Dog]; it won’t compile if you try to give it a Container[Animal].

Contravariance is rarely used

To keep things consistent, I’ve mentioned contravariance second in the preceding discussions, but as a practical matter, contravariant types are rarely used. For instance, the Scala Function1 class is one of the few classes in the standard library that declares a generic parameter to be contravariant, the T1 parameter in this case:

Function1[-T1, +R]

Because it’s not used that often, contravariance isn’t covered in this book, but there’s a good example of it in the free, online Scala 3 Book “Variance” page.

Also note from the Function1 example that a class can accept multiple generic parameters that are declared with variance. -T1 is a parameter that’s only ever consumed in the Function1 class, and +R is a type that’s only ever produced in Function1.

Another type example

For the first edition of the Scala Cookbook I wrote about How to create a timer and how to create your own Try classes. That code uses types heavily, and is still relevant for Scala 3.