Generics in Kotlin, much like in Java, allow developers to create flexible and reusable code that can work with various types while maintaining type safety. This ensures that errors related to types are caught at compile time, leading to more reliable and maintainable codebases. For a deeper dive into Kotlin’s advantages, you can explore Understanding Mobile App Architecture.

How to use type parameters in classes?

Let’s start by looking at how type parameters can be used in classes. In Kotlin, classes can have type parameters just like in Java. Here’s an example of a simple generic class:


class Box<T>(t: T) {
    var value = t
}

// Instantiating the Box class with type arguments:
val intBox: Box<Int> = Box<Int>(1)

In this example, we created a generic class Box that can hold any type of data. When creating an instance of this class, we provided Int as the type argument. However, if the type can be inferred from the constructor arguments, Kotlin allows you to omit the type arguments for brevity:


val inferredBox = Box(1)  // Kotlin infers the type as Box<Int>

For more on handling Kotlin types efficiently, check out this guide to Kotlin Flow and its key features.

Variance in Kotlin

One of the key differences between Kotlin and Java is how variance is handled. In Java, you often need to use wildcards to specify variance. For example, List<String> is not a subtype of List<Object>, which can lead to type-safety issues at runtime. Let’s see an example to explain this:


// Java code
List<String> stringList = new ArrayList<String>();
List<Object> objectList = stringList; // Compile-time error
objectList.add(1); // If this worked, you'd have a problem later when accessing the list.

In Kotlin, there are no wildcard types. Instead, Kotlin uses declaration-site variance and type projections to handle such scenarios more elegantly. You can read more about Kotlin’s variance model in Kotlin Coroutines: Asynchronous Programming in Android.

On the JVM: array types (Array<Foo>) retain information about the erased type of their elements, and type casts to an array type are partially checked: the nullability and actual type arguments of the element type are still erased. For example, the cast foo as Array<List<String>?> will succeed if foo is an array holding any List<*>, whether it is nullable or not.

On the JVM: array types (Array<Foo>) retain information about the erased type of their elements, and type casts to an array type are partially checked: the nullability and actual type arguments of the element type are still erased. For example, the cast foo as Array<List<String>?> will succeed if foo is an array holding any List<*>, whether it is nullable or not.

Covariance in Kotlin: The “out” Modifier

Let’s start with covariance. In Kotlin, if you want a class to produce a value of a generic type but not consume it, you use the out modifier. This is particularly useful for read-only collections or data producers. Check out the following example:


interface Source<out T> {
    fun next(): T
}

fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs // This works because of covariance
}

Here, Source<out T> ensures that the generic type T is only produced and never consumed, meaning that we can safely assign a Source<String> to a Source<Any>. For a more practical application of this, read about Type-safe Navigation in Jetpack Compose.

Contravariance: The “in” Modifier

Contravariance, on the other hand, is useful when a class can consume a value but does not produce it. You apply the in modifier when you are dealing with types that only consume the generic parameter:


interface Comparable<in T> {
    fun compareTo(other: T): Int
}

fun demo(x: Comparable<Number>) {
    x.compareTo(1.0) // 1.0 is a Double, which is a subtype of Number
    val y: Comparable<Double> = x  // Works because of contravariance
}

In this example, Comparable<in T> is contravariant in T. It ensures that T can be consumed, but not produced, allowing you to pass a Comparable<Number> to a variable of type Comparable<Double>. Check out more about Mastering Kotlin Null Safety, another key feature of Kotlin that enhances type safety.

Use-Site Variance: Type Projections

Sometimes, you can’t declare variance at the declaration site, especially in classes where both reading and writing of a generic type are allowed. This is where use-site variance comes into play. Type projections allow us to specify variance when we are using a generic type, rather than when we are defining it.

Consider the following function that copies items from one array to another:


fun copy(from: Array<Any>, to: Array<Any>) {
    assert(from.size == to.size)
    for (i in from.indices) {
        to[i] = from[i]
    }
}

// This won't work:
val ints: Array<Int> = arrayOf(1, 2, 3)
val anyArray = Array<Any>(3) { "" }
copy(ints, anyArray)  // Error

The problem here is that Array<Int> is not a subtype of Array<Any>. To solve this, we can use type projections:


fun copy(from: Array<out Any>, to: Array<Any>) { ... }

// Now, this works:
copy(ints, anyArray)

By using Array<out Any>, we project the type to allow reading from the array without writing to it, which resolves the issue. For a more detailed example, you can check out Concurrency in Kotlin Using Coroutines.

Star-Projection

Sometimes, you want to say that you don’t know the exact type of a generic, but you still want to use it in a safe way. In Kotlin, this is done using star-projection. Star-projection allows you to treat a generic type as a subtype of some known type while still enforcing type safety.


fun process(list: List<*>) {
    for (item in list) {
        println(item)  // Type is `Any?`
    }
}

val intList: List<Int> = listOf(1, 2, 3)
process(intList)  // Safe due to star-projection

Star-projection ensures that we can safely process the list, treating its items as Any?, regardless of the actual type. For more on handling list projections and collections efficiently, check out Mastering Kotlin Collection Builders.

Reified Type Parameters and Type Checks

When working with generics, type information is usually erased at runtime due to type erasure. This means that we can’t perform type checks on generic types at runtime. However, Kotlin provides a way to retain type information with reified type parameters in inline functions:


inline fun <reified T> checkType(value: Any) {
    if (value is T) {
        println("It's of type T!")
    }
}

// Example usage:
checkType<String>("Hello")  // Output: It's of type T!
checkType<Int>("Hello")     // No output, as it's not an Int

The reified keyword allows you to perform type checks on generic parameters, which is not normally possible due to type erasure. For a deeper exploration of Kotlin’s advanced features, see Advanced Navigation Techniques in Jetpack Compose.


Generics in Kotlin provide a powerful way to write flexible and reusable code. By understanding concepts like variance, type projections, and reified type parameters, developers can write safe and scalable code. Whether you’re managing collections, working with variance, or performing type checks with reified parameters, generics allow you to write reusable code without sacrificing safety. For more advanced topics on Kotlin generics and variance, be sure to check out Kotlin’s official documentation or explore more Kotlin tips and tricks on Gorkemkara.net.

Did you like this article?
You can subscribe to my newsletter below and get updates about my new articles.

Shares:
Leave a Reply

Your email address will not be published. Required fields are marked *