Generics: Variance and Constraints of Parametric Types

The desire to reuse code shouldn’t come at a cost of compromising type safety. Generics bring a nice balance to this issue. With generics you can create code that can be reused for different types. At the same time, the compiler will verify that a generic class or a function isn’t used with unintended types. We’ve enjoyed a fairly good amount of type safety with generics in Java. So, you may wonder, what could Kotlin possibly provide to improve? It turns out, quite a bit.

By default, in Java, generics impose type invariance—that is, if a generic function expects a parametric type T, you’re not allowed to substitute a base type of T or a derived type of T; the type has to be exactly the expected type. That’s a good thing, as we’ll discuss further in this section. But what good are rules if there are no exceptions?—and it’s in the area of exceptions that Kotlin comes out ahead.

We’ll first look at type invariance and how Kotlin, just like Java, nicely supports that. Then we’ll dig into ways to change the default behavior.

Sometimes you want the compiler to permit covariance—that is, tell the compiler to permit the use of a derived class of a parametric type T—in addition to allowing the type T. In Java you use the syntax <? extends T> to convey covariance, but there’s a catch. You can use that syntax when you use a generic class, which is called use-site variance, but not when you define a class, which is called declaration-site variance. In Kotlin you can do both, as we’ll see soon.

Other times you want to tell the compiler to allow contravariance—that is, permit a superclass of a parametric type T where type T is expected. Once again, Java permits contravariance, with the syntax <? super T> but only at use-site and not declaration-site. Kotlin permits contravariance both at declaration-site and use-site.

In this section we’ll first review type variance, which is available in Java. Going over it here will help set the context for the much deeper discussions to follow. Then, you’ll learn the syntax for covariance both for declaration-site and use-site. After that, we’ll dive into contravariance and, finally, wrap up with how to mix multiple constraints for variance.

Type invariance

When a method receives an object of a class T, you may pass an object of any derived class of T. For example, if you may pass an instance of Animal, then you may also pass an instance of Dog, which is a subclass of Animal. However, if a method receives a generic object of type T, for example, List<T>, then you may not pass an object of a generic object of derived type of T. For example, if you may pass List<Animal>, you can’t pass List<Dog> where Dog extends Animal. That’s type invariance—you can’t vary on the type.

Let’s use an example to illustrate type invariance first, and then we’ll build on that example to learn about type variance. Suppose we have a Fruit class and two classes that inherit from it:

// typeinvariance.kts
open class Fruit
class Banana : Fruit()
class Orange: Fruit()

Now suppose a basket of Fruits is represented by Array<Fruit> and we have a method that receives and works with it.

// typeinvariance.kts
fun receiveFruits(fruits: Array<Fruit>) {
  println("Number of fruits: ${fruits.size}")
}

Right now, the receiveFruits() method is merely printing the size of the array given to it. But, in the future, it may change to get or set an object from or into the Array<Fruit>. Now if we have a basket of Bananas, that is Array<Banana>, then Kotlin, like Java, won’t permit us to pass it to the receiveFruits() method:

// typeinvariance.kts
val bananas: Array<Banana> = arrayOf() 
receiveFruits(bananas) //ERROR: type mismatch

This restriction is due to Kotlin’s type invariance with generic types—a basket of Bananas doesn’t inherit from a basket of Fruits. There’s a really good reason for this. Inheritance means substitutability—that is, an instance of derived may be passed to any method that expects an instance of base. If an instance of Array<Banana> were allowed to be passed as argument where an instance of Array<Fruit> was expected, we may be in trouble if the receiveFruits() method were to add an Orange to the Array<Fruit>. In this case, when processing Array<Banana>, we’ll run into a casting exception later when we try to treat that instance of Orange as Banana—no orange ever likes that kind of treatment. Alternatively, we may attempt to implement the receiveFruits() method so that it adds an Orange only if the given parameter isn’t an Array<Banana>, but such a type check will result in the violation of the Liskov Substitution Principle—see Agile Software Development, Principles, Patterns, and PracticesRobert C. Martin. Agile Software Development, Principles, Patterns, and Practices. Prentice Hall, Englewood Cliffs, NJ, 2002.

Even though Banana inherits from Fruit, by disallowing the Array<Banana> to be passed in where Array<Fruit> is expected, Kotlin makes the use of generics type safe.

Before we move forward, let’s make a slight change to the code to understand what at first appears like a quirk but is actually the sign of a sound type system.

fun receiveFruits(fruits: List<Fruit>) { 
  println("Number of fruits: ${fruits.size}")
}

We changed the parameter type from Array<Fruit> to List<Fruit>. Now, let’s pass an instance of List<Banana> to this function:

Get hands-on with 1200+ tech skills courses.