Kotlin’s Type System – Special Types, Generics, Variance, Projections

The Kotlin compiler and type system are our best friends when it comes to writing reliable and secure code, so we should always try to make best use of the provided language features.

In this blog post, we’ll have a look at:

  • Kotlin’s type hierarchy and special types (T?, Any, Unit, Nothing)
  • generic types, upper bounds, and type erasure
  • covariant, contravariant, and invariant generic types
  • projected types (in, out, *)

There’s a TL;DR after each section but I’ll provide brief explanations and motivational examples as well. 😎

See also the Kotlin Language Guide on parts of the content presented here. You also might be interested in some older blog posts:

Table of Contents

Kotlin’s Type Hierarchy

Let us first take a quick look at Kotlin’s most important types and their precise meaning.

Nullable Types

As you probably know, Kotlin strictly distinguishes nullable and non-nullable types. Every type T has a nullable counterpart T?. The objects of T are never null, and the objects of T? are precisely those of T and null. This is a core design principle of Kotlin.

Small Remark – Note that null isn’t a type itself, it’s a value that belongs to every nullable type (Int?, String?, …). The return type of a nonsensical function that only returns null would be Nothing?, which is a type that contains only the object null (see below).

Any

The Any type is the most general non-nullable type that Kotlin has to offer. Every non-null object is of type Any, so every non-nullable type is a sub-type of Any.

It follows that Any? is truly the most general type in Kotlin, since it also includes null. Every object is of type Any?, and every type is a sub-type of Any?.

Unit

The Unit type is a type with a special meaning. It is a type with only one object (hence the name “Unit”) which does not carry any interesting information.

We usually want functions to return Unit when they only perform side effects. The Unit return type then means: That function – in fact – did something and returned, but no object or value of interest came out of it.

So to speak, Unit signalizes that a function returns, not what it returns.

Nothing

The Nothing type is another special type. It has no objects.

That means that any object you can think of (any Int, String, …) can never be of type Nothing.

It trivially follows that Nothing is a sub-type of every other type (since there are no objects of Nothing that could violate this condition).

In particular, if a function () -> Int for some reason returns Nothing, the result is still an Integer (since Nothing is a sub-type of Int).

This is – for instance – the case if the function throws. And a function that always throws (for instance, the TODO function) will always just return Nothing, as it doesn’t return.

If you’re familiar with Arrow, you’ll also know that every None is an Option<Nothing>, every Left<A> is an Either<A, Nothing>, etc.

An Illustration

I have drawn a neat little directed graph to illustrate this. An arrow means “is sub-type of” (I didn’t draw all possible arrows of the transitive closure though).

The key observation: Nothing is a lower bound of every type, and Any? is an upper bound of every type, i.e. they are the “minimum” and “maximum” types in Kotlin’s type system. No object is Nothing and every object is Any?.

TL;DR

  • T? is the nullable counterpart of the non-nullable type T, i.e. T? is precisely T and null
  • Any? is the largest type, containing every object
  • Unit contains exactly one object
  • Nothing is the smallest type, containing no objects

Generic Types

Kotlin allows classes, interfaces, and functions to have type parameters. A type with a type parameter is called a generic type.

A universal example would be the type List<T> – a list of that type is guaranteed to only contain elements of type T. Or the type Pair<A, B>, whose objects are tuples with the first entry of type A and the second entry of type B.

We can then define generic functions like this:

fun <A, B> Pair<A, B>.swap(): Pair<B, A> = Pair(second, first)

Upper Bounds

If we want to restrict the type parameter of a generic class, interface, or function to be a sub-type of another type, we can do it like this:

interface Spawner<T : Animal> {
fun spawn(): T
}

Then Spawner<Cat> spawns cats, and Spawner<Animal> spawns any kind of animal, …

Multiple Upper Bounds

Sometimes we want to enforce multiple upper bounds for a type parameter that should hold at the same time. We can use Kotlin’s where keyword for this.

fun <T> T.makeNoiseAndGo() where T : Animal, T : Vehicle {
makeNoise() // meow
go() // drives off
}

Then T is assumed to be both Animal and Vehicle at the same time. This way, we can define extension functions on type intersections.

Type Erasure

Note that generic types are a compile-time feature only! This doesn’t work:

fun <T> Any?.isOfType(): Boolean = this is T
fun Any?.isCat(): Boolean = isOfType<Cat>()

The reason is that the type parameter T gets erased at runtime. In order to make this code work, we have to inline isOfType and reify T.

inline fun <reified T> Any?.isOfType(): Boolean = this is T

Then, we can happily define and call cat.isCat().

TL;DR

  • Kotlin supports classes, interfaces, and functions with type parameters
  • you can declare one or (using the where keyword) more upper bounds for type parameters
  • type parameters are erased at runtime unless reified

Variance

Assume we have two types with a sub-type relationship (say, Cat and Animal).

The term “variance” means whether and how the sub-type relationship Cat ⊂ Animal is carried over to generic types SomeType<Cat> and SomeType<Animal>.

Covariance

Let’s look at the Spawner code from the previous section again.

interface Spawner<T : Animal> {
fun spawn(): T
}

The technical model is clear: Every cat is a special case of an animal, so every cat spawner is a special case of an animal spawner.

That means we want Spawner<Cat> to be a sub-type of Spawner<Animal> on the code level as well.

The compiler doesn’t know this automatically (and it shouldn’t, as this implication might not hold for other technical models), but we can let it know of this fact by adding the out keyword before the type parameter.

interface Spawner<out T : Animal> {
fun spawn(): T
}

We say that Spawner<T> is covariant in T – it preserves the sub-type relation.

Note that the out declaration can only be applied if the interface only defines methods that have T as output. We are not allowed to do this if T somewhere appears as a function argument – see below why.

Contravariance

Let us now define a PokeBall interface that catches a specific kind of animal, like this.

interface PokeBall<T : Animal> {
fun capture(animal: T)
}

Note that T is now being consumed instead of being produced.

The technical model is now like this: A PokeBall<Cat> only captures cats, and a PokeBall<Animal> captures any kind of animal – in particular, cats! It follows that every animal Pokéball is automatically a cat Pokéball.

That means we now want PokeBall<Animal> to be a sub-type of PokeBall<Cat>, which is the other way around as in the Spawner example.

Again, we can let the compiler know this by now adding the in keyword before the type parameter:

interface PokeBall<in T : Animal> {
fun capture(animal: T)
}

We say that PokeBall<T> is contravariant in T – it reverses the sub-type relation.

Again, the in declaration can only be applied if the interface only defines methods that have T as input. We are not allowed to do this if T somewhere appears as a function result.

Invariance

It is obvious that any meaningful generic type cannot be covariant and contravariant in its type parameter at the same time. Look at the following example:

interface Dealer<T : Animal> {
fun sell(): T
 
fun buy(animal: T)
}

We can neither declare T as out nor as in. The compiler prevents us, and the implications wouldn’t make any sense: The statements Dealer<Cat> ⊂ Dealer<Animal> and Dealer<Animal> ⊂ Dealer<Cat> cannot be true the same time.

We call such a type invariant in its type argument.

Invariant types are a little less flexible than covariant or contravariant types. Assume we want to browse the inventory of an animal dealer:

fun browseAnimals(dealer: Dealer<Animal>): List<Animal>

This definition would be problematic: browseAnimals cannot have a cat dealer as its argument because Dealer<Cat> is not a sub-type of Dealer<Animal>.

Similarly, assume we want to sell all our hamsters to a hamster dealer:

fun supplyWithHamsters(dealer: Dealer<Hamster>, hamsters: List<Hamster>) {
hamsters.forEach { dealer.buy(it) }
}

Then this would be problematic as well: We cannot pass an animal dealer to this function because Dealer<Animal> is not a sub-type of Dealer<Hamster>.

One way to resolve this would be splitting up the Dealer interface in one covariant and one contravariant type like this:

interface Seller<out T : Animal> {
fun sell(): T
}
 
interface Buyer<in T : Animal> {
fun buy(animal: T)
}

But this doesn’t seem convenient in every situation… I’ll show you another way using type projections in the next section.

TL;DR

  • add the out keyword to make a generic type covariant (preserve the sub-type relation) – only possible if there are only producer methods
  • add the in keyword to make a generic type contravariant (reverse the sub-type relation) – only possible if there are only consumer methods
  • if neither is possible, the type is invariant (and one has to be careful with signatures)

Projected Types

With only a small tweak, we can make the browseAnimals and supplyWithHamsters functions behave as expected.

The “out” Projection

We can add the out keyword in the function definition as follows:

fun browseAnimals(dealer: Dealer<out Animal>): List<Animal>

Here, Dealer<out Animal> is the “covariant part” of the Dealer<Animal> type, i.e. we only have access to the “out” methods – in this case, sell. We cannot access buy because buy breaks covariance! So to speak, the projected type is what could have been defined as Seller<Animal>.

With this little tweak, we can happily call browseAnimals(catDealer)!

The “in” Projection

Similarly, we can add the in keyword like this:

fun supplyWithHamsters(dealer: Dealer<in Hamster>, hamsters: List<Hamster>) {
hamsters.forEach { dealer.buy(it) }
}

Of course, the type Dealer<in Hamster> is then the “contravariant part” of Dealer<Hamster>, and we can access all “in” methods – in this case, buy. But again, we cannot access sell because sell breaks contravariance. The projected type is what could have been defined as Buyer<Hamster>.

Now, calling supplyWithHamsters(animalDealer) works just fine.

Declaration-site Variance vs. Use-site Variance

If we define types like Spawner<out T : Animal>, the out-declaration happens at the class or interface definition level, so it’s known to the compiler everywhere. This kind of variance is called declaration-site variance.

On the other hand, if we use out and in in function arguments, only the function knows of the variance, which is why this is called use-site variance.

Note that while out and in function arguments are a convenient little tweak, we lose type information.

  • The result of browseAnimals(catDealer) will be typed as List<Animal>, losing the information that the dealer specifically sells cats.
  • And a Dealer<Animal> supplied with hamsters will become a Dealer<Hamster>, losing the information that the dealer possibly sells other animals than hamsters.

So we should use it only when we don’t care about the specific types. (Otherwise, it would be better to just write a generic function instead.)

The Star Projection

Lastly, have a look at the following code snippet.

val catDealer: Dealer<Cat> = provideCatDealer()
val dogDealer: Dealer<Dog> = provideDogDealer()
 
val dealers = listOf(catDealer, dogDealer)

What is the type of dealers?

We might expect it to be Dealer<Animal>… But this cannot be the case because neither Dealer<Cat> nor Dealer<Dog> are sub-types of Dealer<Animal> (as the type is invariant). In fact, the smallest common super-type of catDealer and dogDealer is just Any.

But wait! We know that the objects of dealers are in fact of type Dealer<...> – how to resolve this?

For this purpose, Kotlin provides the star projection. We can write something like this:

fun buyOneAnimalFromEachDealer(dealers: List<Dealer<*>>): List<Animal> =
dealers.map { it.sell() }

The projected type Dealer<*> means: It’s a dealer that deals with animals of some kind – but we don’t know which kind and we don’t care as well. We’re totally fine if the result is just List<Animal>, living a life in blissful ignorance.

Note that putting List<Dealer<Animal>> there doesn’t work nicely, as then we wouldn’t be able to pass the previously defined list dealers as an argument to this function. Also note that we cannot access buy because, again, it breaks variance (we might not be able to sell dogs to a cat dealer).

If there isn’t a specific common upper bound of the type parameters, then SomeType<*> just resolves to SomeType<out Any?>.

TL;DR

  • the out and in keywords can be applied to generic types in function arguments as well (use-site variance as opposed to declaration-site variance)
  • we then get projections of our generic type onto its “covariant part” or “contravariant part” only within that function (the trade-off being type information)
  • we can use the star projection to “group” multiple objects of the same generic type if we don’t care about the type parameter

Summary

You should now be able to understand:

  • Kotlin’s special types T?, Unit, Any, and Nothing
  • how to define generic types with one or more upper bounds on their type parameters
  • how to use the out and in keywords to make your types declaration-site variant
  • how to use the out and in keywords to make your types use-site variant via projection
  • the difference between SomeType<*> (star projection) and SomeType<Any?>

I hope that these explanations help you writing safer and more expressive Kotlin code. 🙂

Discover more from Spree-Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading

Cookie Consent Banner by Real Cookie Banner