Kotlin is a programming language that was developed by JetBrains, the team behind IntelliJ IDEA, Pycharm, and other IDEs that make our lives as programmers easier. Kotlin does this by allowing us to write more concise code while being safer than other programming languages, such as Java.
Let’s take a look at Kotlin, why we need Kotlin generics, and dive into the concept of generics in depth.
Here’s what we’ll cover in this guide:
The JetBrains team initially created Kotlin for internal use. Java was making the JetBrains codebase difficult to maintain, so they were in need of a more modern language.
Since Kotlin provides complete Java interoperability, it is easy to use on both projects being built from the ground up and existing codebases where the developers prefer to adopt the new approach. Kotlin has replaced Java as the preferred language for developing Android apps.
Currently, more than 80 percent of the top 1,000 apps from the Google Play Store use Kotlin, and backend developers are also starting to use it more and more. In addition, Kotlin Multiplatform is becoming increasingly popular, while Jetpack Compose is widely used on new projects.
We must note that Kotlin is a statically typed programming language, meaning we have to specify and be aware of the types of all variables at compile time.
Dynamically typed languages, such as Python, can offer the developer more flexibility when writing code. However, this practice is prone to runtime errors since variables can take any value of any type.
By specifying types for our variables, we can stay consistent and write more robust code that is also easier to maintain and debug. Why? Because compile-time errors are easier to spot and fix than runtime errors.
Using a strongly typed language such as Kotlin might make a developer feel constrained sometimes.
We all liked Python when we were first-year computer science students because it lets us write anything. But because we didn’t know how to write proper code and other best practices, we ended up with a bowl of impossible-to-debug spaghetti code.
Don’t worry, there’s a wonderful solution to this exact problem! This solution is referred to as generic programming and is usually bundled with definitions that are stuffy and difficult to decipher.
In this article, we are going to use a laid-back approach focused on helping you get the concepts, reviewing:
class
, subclass
, type
, and subtype
in
and out
keywords map to these termsTowards the end of this reading, you’ll be fully prepared to use Kotlin generics in any project.
Generic programming is a way of writing our code in a flexible manner like we would in a dynamically-typed language. At the same time, generics allow us to write code safely and with as few compile-time errors as possible.
Using generics in Kotlin enables the developer to focus on creating reusable solutions, or templates, for a wider range of problems.
We can define a template as a partially filled solution that can be used for a variety of situations. We fill in the gaps when we actually use that solution (for instance, a class) and provide an actual type for it.
When reading about generic types and inheritance, we’ll notice that the words class
, subclass
, type
, and subtype
are thrown around. What exactly is the difference between them?
A class
is a blueprint of the objects that will be instantiated using it. These objects will inherit all the fields and methods that were declared in that class.
A subclass
is a class that is derived from another class. Simply put, our subclass will inherit all the methods and fields that exist in the parent class.
We can then say these objects all have the same type
defined by the class. Types should mainly focus on the interface of an object, not on the concrete implementation that can be found in the classes that are used when instantiating objects.
A subtype
will be created when a class inherits a type from another class or implements a specific interface.
Now let’s return to generics and understand why we need them in a statically typed language like Kotlin.
In the next code snippet, we define a stack that can be used for the sole purpose of handling integers:
class IntStack { private val elements: MutableList<Int> = ArrayList() fun pop(): Int { return elements.removeLast() } fun push(value: Int) { elements.add(value) } // ... }
Nothing fancy for now. But what happens if we need to store integer strings, or even puppies? Then we’d need to create two more classes: StringStack
and PuppyStack
.
Would the puppy stack do anything differently than the integer stack (except for being more adorable, obviously)? Of course not. As a result, there’s no need to create separate classes for each case. It’s enough to create a generic stack that can be used anywhere in our project:
class Stack<T> { private val elements: MutableList<T> = ArrayList() fun pop(): T { return elements.removeLast() } fun push(value: T) { elements.add(value) } // ... }
Now we can use this data structure for stacking anything we want, no matter how adorable or dull it is.
But what if we need to impose some restrictions on the situations where our generic class can be used? These restrictions might implement behaviors that don’t apply to every single situation. This is where we introduce the concepts of variance, covariance, contravariance, and invariance.
Variance refers to the way in which components of different types relate to each other. For example, List<Mammal>
and List<Cat>
have the same base type (List
), but different component types (Mammal
and Cat
).
It’s important to understand how lists of these two types would behave in our code and whether or not they are compatible with our purpose. For instance, take a look at the following code snippet:
open class Mammal { ... } class Cat: Mammal() { ... } class Dog: Mammal() { ... } val animals: MutableList<out Mammal> = mutableListOf() animals.add(Dog(), Cat())
In the code above, variance tells us that a Dog
and a Cat
will have the same rights in a list that’s defined as List<Mammal>
.
The code below would work, too:
val dogs: List<Dog> = listOf(Dog()) val mammal: Mammal = dog.first()
Covariance allows you to set an upper boundary for the types that can be used with the class. If we were to illustrate this concept using the stack that we defined above, we’d use the keyword out
.
For a concrete example, we can take a look at the definition and an instantiation of List<>
from Kotlin:
public interface List<out E> : Collection<E> { ... } ... val numbers: List<Number> = listOf(1, 2, 3.0, 4, ...)
By doing something like this, we are essentially defining an upper bound for the elements of this list and relaxing the limitations put on our generic types.
In other words, whenever we retrieve an element from the list created above, we know for sure that the element will be of at least type Number
. As a result, we can safely rely on any attribute or behavior of the Number
class when working with the elements of our list.
Let’s take a look at a different example:
class PetOwner<T> // !!! This won't work: it's a type mismatch val petOwner1: PetOwner<Animal> = PetOwner<Cat>() // This will work: we tell the compiler that petOwner2 accepts lists of its type's subtypes too val petOwner2: PetOwner<out Animal> = PetOwner<Cat>()
Covariance is very useful when we want to limit our usage to subtypes only:
val mammals: List<out Mammal > = listOf(Dog(), Cat()) mammals.forEach { mammal -> mammal.move() }
By instantiating our mammals
list with the above syntax, we ensure that only subtypes of the type Mammal
can be contained in and retrieved from a list.
In a more real-world scenario, we could think of a superclass User
and two subclasses Moderator
and ChatMember
. These two subclasses can be stored together in a list defined as List<out User>
.
But what if we had a case where we wanted to do an operation only on those members that have a certain degree of rights and responsibilities in our scenario?
This is where we’d want to set a lower boundary. More specifically, when using the syntax Stack<in T>
, we are able to only manipulate objects that are at most of type T
.
val superUsersList: MutableList<in Moderator> = mutableListOf()
With the above syntax, we are therefore creating a list that will only accept objects of type Moderator
and above (such as User
, the supertype of User
— if it has one — and so on).
Here’s a more interesting example of contravariance in Kotlin:
val userComparator: Comparator<User> = object: Comparator<User> { override fun compare(firstUser: User, secondUser: User): Int { return firstUser.rank - secondUser.rank } } val moderatorComparator: Comparator<in Moderator> = userComparator
The above syntax is correct. What we’re doing is defining a comparator that can be used for any kind of user. Then we declare a comparator that only applies to moderators and assigns to it the users
comparator. This is acceptable since a Moderator
is a subtype of User
.
How is this situation contravariant? The userCompare
comparator specializes in a superclass, whereas the moderator comparator is a subclass that can be assigned a value that depends on its superclass.
The equivalent of these concepts in Java is as follows:
List<out T>
in Kotlin is List<? extends T>
in JavaList<in T>
in Kotlin is List<? super T>
in JavaInvariance is easy to understand: basically, every class that you define with a generic type with no in
or out
keyword will be considered to be invariant. This is because there will be no relationship between the types that you created using generics.
Let’s look at an example to clear things up:
open class Animal class Dog: Animal() val animals: MutableList<Animal> = mutableListOf() val dogs: MutableList<Dog> = mutableListOf()
In the above example, we see that there’s a clear relationship between Dog
and Animal
: the former is a subtype of the latter. However, we can’t say the same about the types of the two list variables. There is no relationship between those two. Therefore, we can say that List
is invariant on its type parameter.
All Kotlin generic types are invariant by default. For example, lists are invariant — as we saw above. The purpose of the in
and out
keywords is to introduce variance to a language whose generic types don’t allow it otherwise.
When using generics in Kotlin, we must also avoid misusing our methods and classes in ways that can lead us to errors. We must use in
and out
to impose declaration-site variance for our types.
In some situations, we must use generics with our method definitions such that the parameters passed to them will respect a set of prerequisites. These prerequisites ensure that our code can actually run. Let’s check out an example:
open class User class Moderator: User() class ChatMember: User()
Let’s say that we wanted to sort our users based on a criterion (their age, for example). Our User
class has an age
field. But how can we create a sorting function for them? It is easy, but our users must implement the Comparable
interface.
More specifically, our User
class will extend the Comparable
interface, and it will implement the compareTo
method. In this way, we ensure that a User
object knows how to be compared to another user.
fun <T: Comparable<T>> sort(list: List<T>): List<T> { return list.sorted() }
From the above function declaration, we understand that we can strictly use the sort
method on lists that contain object instantiations of classes that implement the Comparable
interface.
If we were to call the sort
method on a subtype of Animal
, the compiler would throw an error. However, it will work with the User
class since it implements the compareTo
method.
It is also interesting to note that Kotlin, just like Java, performs type erasure when compiling our code. This means that it first checks our types and either confirms that we used them correctly or throws errors that tell us to do better next time. Afterward, it strips the type information from our generic types.
The compiler wants to make sure that types are not available to us at runtime. This is the reason why the following code would not compile:
class SimpleClass { fun doSomething(list: List<String>): Int { ... } fun doSomething(list: List<Int>): Int { ... } } fun main() { val obj = SimpleClass() }
This is because the code compiles correctly, with the two methods having actually different method signatures. However, type erasure at compile time strips away the String
and Int
types that we used for declaring our lists.
At runtime, we only know that we have two lists, without knowing what type the objects are from those two lists. This outcome is clear from the error that we get:
Exception in thread "main" java.lang.ClassFormatError: Duplicate method name "doSomething" with signature "(Ljava.util.List;)I" in class file SimpleClass
When writing our code, it’s worth keeping in mind that type erasure will happen at compile time. If you would really want to do something like we did in the above code, you’d need to use the @JvmName
annotation on our methods:
@JvmName("doSomethingString") fun doSomething(list: List<String>): Int { ... } @JvmName("doSomethingInt") fun doSomething(list: List<Int>): Int { ... }
There are several things that we covered in this article in order to understand Kotlin generics.
We first clarified the difference between a type and a class when working in Kotlin (and any object-oriented language). Afterward, we introduced the concept of generics and their purpose.
To dive deeper into Kotlin generics, we checked out some definitions accompanied by examples that showed us how generics are used and implemented in Kotlin compared to Java, a very similar language.
We also understood variance, covariance, contravariance, and invariance in Kotlin and learned how (and when) to apply these concepts in our projects by the means of the in
and out
keywords.
The key takeaway of this article is that generics can be used in our code in order to keep it simple, maintainable, robust, and scalable. We ensure that our solutions are as generic as possible when they need to be — it’s also important not to complicate our lives by trying to make everything generic.
Sometimes this practice could make everything more difficult to follow and to put into practice, so it’s not worth using generics if they don’t bring true value to us.
By using generics in Kotlin, we avoid using casts, and we catch errors at compile time instead of runtime. The compiler ensures that we use our types correctly before performing type erasure.
I hope that this helped you and that it clarified the concepts related to Kotlin generics. Thanks a lot for reading!
LogRocket is an Android monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your Android apps.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.
Start proactively monitoring your Android apps — try LogRocket for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowReact Native’s New Architecture offers significant performance advantages. In this article, you’ll explore synchronous and asynchronous rendering in React Native through practical use cases.
Build scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.