Advanced Kotlin Generics: Type Variance, Constraints, and Reified Types

Kotlin’s type system provides robust features, including generics, which allow you to write flexible and reusable code. Diving deeper into Kotlin generics unveils advanced concepts such as type variance, constraints, and reified types. Mastering these aspects can lead to more type-safe, efficient, and expressive code. In this blog post, we’ll explore these advanced features of Kotlin generics with detailed examples.

Understanding Generics in Kotlin

Before diving into the advanced concepts, let’s briefly recap the basics of Kotlin generics. Generics allow you to parameterize classes, interfaces, and functions with type parameters. This enables you to write code that works with various types while maintaining type safety.

Basic Generic Example


class Box(val value: T)

fun main() {
    val intBox: Box = Box(10)
    val stringBox: Box = Box(\"Hello\")

    println(intBox.value)    // Output: 10
    println(stringBox.value) // Output: Hello
}

Type Variance

Type variance deals with how generic types behave concerning their subtypes. Kotlin supports three types of variance:

Covariance with out

To declare a generic type parameter as covariant, use the out keyword. This means the type can only be used as an output (return type) and not as an input (parameter type).


interface Source {
    fun next(): T
}

class StringSource(val value: String) : Source {
    override fun next(): String = value
}

fun demo(stringSource: Source) {
    val objectSource: Source = stringSource // OK: String is a subtype of Any
    println(objectSource.next())
}

fun main() {
    val stringSource = StringSource("Hello")
    demo(stringSource) // Output: Hello
}

In this example, Source is covariant in T. This means a Source can be safely treated as a Source because String is a subtype of Any.

Contravariance with in

To declare a generic type parameter as contravariant, use the in keyword. This means the type can only be used as an input (parameter type) and not as an output (return type).


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

class Box(val value: Double) : Comparable {
    override fun compareTo(other: Number): Int {
        return value.compareTo(other.toDouble())
    }
}

fun demo(box: Comparable) {
    val doubleBox: Comparable = box // OK: Number is a supertype of Double
    println(doubleBox.compareTo(3.14))
}

fun main() {
    val box = Box(2.71)
    demo(box) // Output: -1
}

In this example, Comparable is contravariant in T. This means a Comparable can be safely treated as a Comparable because Number is a supertype of Double.

Invariance (No Variance)

By default, generic types in Kotlin are invariant. This means there is no subtype relationship between instances of generic types, even if their type parameters have a subtype relationship.


class InvariantBox(var value: T)

fun main() {
    val intBox: InvariantBox = InvariantBox(10)
    // val numberBox: InvariantBox = intBox // Error: Type mismatch

    val numberBox: InvariantBox = InvariantBox(10)
    // val intBox2: InvariantBox = numberBox // Error: Type mismatch
}

In this case, even though Int is a subtype of Number, InvariantBox is not a subtype or supertype of InvariantBox.

Type Constraints

Type constraints allow you to restrict the types that can be used as type parameters. Kotlin supports both upper bounds and multiple constraints.

Upper Bounds

An upper bound restricts the type parameter to be a subtype of a specific type.


fun  half(value: T): Double {
    return value.toDouble() / 2
}

fun main() {
    println(half(10))     // Output: 5.0
    println(half(3.14))   // Output: 1.57
    // println(half("Hello")) // Error: Type argument is not within its bounds: should be a subtype of 'Number'
}

Here, T is constrained to be a subtype of Number, ensuring that only numbers can be passed to the half function.

Multiple Constraints

You can specify multiple constraints using the where keyword.


interface Printable {
    fun print(): String
}

interface Sized {
    fun size(): Int
}

fun  process(item: T) where T : Printable, T : Sized {
    println("Item: ${item.print()}, Size: ${item.size()}")
}

class Data(val name: String, val count: Int) : Printable, Sized {
    override fun print(): String = "Name: $name"
    override fun size(): Int = count
}

fun main() {
    val data = Data("Example", 42)
    process(data) // Output: Item: Name: Example, Size: 42
}

In this example, T must implement both Printable and Sized interfaces, allowing the process function to call methods from both interfaces.

Reified Types

Normally, type erasure in Java and Kotlin means that type parameters are not available at runtime. However, Kotlin provides reified types, allowing you to access type information at runtime.

Using reified with Inline Functions

To use reified types, you must declare the function as inline.


inline fun  isA(value: Any): Boolean {
    return value is T
}

fun main() {
    println(isA("Hello")) // Output: true
    println(isA(10))       // Output: true
    println(isA(10))     // Output: false
}

Here, the isA function can check the type of the value at runtime, which is not normally possible with generics due to type erasure.

Example: Filtering a List

Reified types are particularly useful when filtering lists based on type at runtime.


inline fun  List<*>.filterByType(): List {
    return filterIsInstance()
}

fun main() {
    val mixedList: List = listOf("Hello", 10, 3.14, "World", 20)
    val stringList: List = mixedList.filterByType()
    println(stringList) // Output: [Hello, World]

    val intList: List = mixedList.filterByType()
    println(intList) // Output: [10, 20]
}

In this example, filterByType uses reified T to filter the list and return only elements of the specified type.

Conclusion

Advanced Kotlin generics, including type variance, constraints, and reified types, enable you to write more expressive and type-safe code. Understanding covariance and contravariance allows you to create flexible type hierarchies. Type constraints ensure that your generic code operates on specific types, enhancing safety. Reified types provide runtime type information, enabling powerful features like type-based filtering. Mastering these concepts will significantly enhance your ability to leverage Kotlin’s generics system effectively.