9 minutes read

Previously, we have seen that generics can accept any type of parameters and make code reusable. Let's get familiar with another aspect of generics now. Sometimes we need to restrict the type parameter inside a generic function or class. For example, we have a generic class Storage<T> and we want to make it store only books without creating one more class. In such a situation, we should use type bounds.

Usage with classes

We have mentioned a generic class named Storage<T> above. Let's take a look at its code:

class Storage<T>() {
      // some code
}

Before, we wanted to save only books inside this storage. But a "book" is quite a wide concept – it can include magazines, brochures, etc. We can add our limitation by adding a constraint T : Book inside angle brackets:

class Storage<T : Book>() {
      // some code
}

Then we create the classes whose objects we want to store:

open class Book {}
class Magazine : Book() {}
class Stone {}

We've created three classes: Book, Magazine, and Stone, and, as you can see, Magazine inherits Book. Now let's create instances of Storage<T>:

val storage1 = Storage<Book>()
val storage2 = Storage<Magazine>()
val storage3 = Storage<Stone>() // compile-time error

The first two lines will compile without problems. The third one, however, will return an error: Type parameter 'Stone' is not within its bounds. Since this is a compile-time error, we catch this problem before it can appear in a real application. This makes type bounds safe to use.

By default, all type parameters are constrained by the type Any?. The definition of any generic class, for example, SomeGeneric<T> is the same as SomeGeneric<T : Any?>.

As constraints, we can set not only classes but interfaces, too. Do not try to extend one generic class from another (like Storage<Magazine> : Storage<Book>) — it will lead to an error.

Usage with functions

Type bounds can be used with generic functions, too. The principle of usage with functions is the same as with classes.

fun <T : Book> sortByDate(list: List<T>) { ... }

This function takes List<T> as an argument type. The type Book is specified after a colon: it's the upper bound. It means that only a type that extends Book can be substituted for T. Imagine that we have two lists: the first one is named listOne and it stores values of the type Magazine, which extends Book. The second list listTwo stores values of the type String.

/* create instances */
var listOne: List<Magazine> = listOf();
var listTwo: List<String> = listOf();

/* invoke methods */
sortByDate(listOne) // OK because Magazine is a subtype of Book
sortByDate(listTwo) // Error: String is not a subtype of Book

As we can see, we have no problems invoking sortByDate() with listOne as an argument. But we do have problems when we try to pass listTwo. String is not a subtype of Book, and that's why we can't pass List<String> to sortByDate().

Definitely non-nullable types

In addition to the type bounds we've covered, there's another concept that is essential to understand when working with generics, especially in the context of interoperability with Java—the concept of definite non-nullability. This is particularly useful when dealing with generic Java classes and interfaces.

Since Kotlin 1.7.0, we can declare a generic type parameter as definitely non-nullable. This is achieved by declaring the type with the syntax T & Any (intersection type of T and Any). Here, T is the generic type that is asserted as non-nullable. However, it's important to note that a definitely non-nullable type must have a nullable upper bound (remember, all type parameters have a default upper bound: Any?, which is nullable).

Suppose we have the following Java interface Game<T>:

import org.jetbrains.annotations.*;

public interface Game<T> {
    public T save(T x);

    @NotNull
    public T load(@NotNull T x);
}

In this interface, the load() method uses the @NotNull annotation, which means it expects a non-nullable argument. To override this method in Kotlin, the type T1 needs to be declared as definitely non-nullable:

interface ArcadeGame<T1> : Game<T1> {
    override fun save(x: T1): T1

    override fun load(x: T1 & Any): T1 & Any // T1 is definitely non-nullable

 // override fun load(x: T1): T1
 // ^ This won't compile
}

This ensures that the load() function in the ArcadeGame interface will not accept nullable types, adhering to the original Java interface contract.

Next, let's consider a pure Kotlin example:

fun <T : String?> elvisLike(first: T, second: T & Any): T & Any = first ?: second

fun main() {
    elvisLike<String>("", "123").length    // Compiles successfully and returns 0

    elvisLike<String>("", null).length     // Compilation error: 'null' cannot be a value of a non-null type

    elvisLike<String?>(null, "123").length // Compiles successfully and returns 3

    elvisLike<String?>(null, null).length  // Compilation error: 'null' cannot be a value of a non-null type
}

Here, we've defined a function named elvisLike that emulates the behavior of Kotlin's Elvis operator (?:). Notice that the generic type of the function has an upper bound that's nullable (String?). If we set the upper bound to be non-nullable (String), we wouldn't be able to designate the generic type of the second parameter as definitely non-nullable.

The elvisLike function behaves as expected when we use it in our code. Whenever we attempt to pass a null value as the second argument, a compilation error arises. However, passing null as the first argument doesn't cause any issues, which is precisely the behavior of the Elvis operator: it returns the first value if it's not null; otherwise, it returns the second value, which is expected to be non-null.

It's worth mentioning though that when you're working exclusively with Kotlin, the need to explicitly declare definitely non-nullable types is rare. This is due to Kotlin's robust type inference system, which usually handles this aspect automatically.

Multiple bounds

Type variables may have multiple bounds, but only one upper bound can be specified inside the angle brackets. If you need multiple type bounds, you need to use the where-clause to separate them. Multiple bounds pose stronger restrictions on the type variable so that it should match the specified types. Imagine that we have an interface Watchable, which is generic, and we want to pass object realizations of this interface to sortByDate (in order to sort films and news by date, for example).

fun <T> sortByDate(list: List<T>)
     where T : Book, T : Watchable<T> {...}

Here we use multiple type bounds and we specify that only the type which extends Book and implements Watchable can be used as type variable. Consider the fact that Kotlin (like Java) does not support multiple inheritance. It means that a class can extend only one class. But there is good news — a class can implement an unlimited number of interfaces!

When you use multiple bounds, the first type should be a class or an interface. The following types must be interfaces.

Conclusion

Type bounds are used to restrict type parameters. The most common use of type bounds is setting upper bounds. It may come in handy if you want to limit the types a class can store or a function can take. You can also set multiple bounds, but remember about single inheritance!

75 learners liked this piece of theory. 5 didn't like it. What about you?
Report a typo