Skip to main content

Kotlin Contravariance

In Kotlin's type system, contravariance is a powerful concept that allows you to create more flexible and safer generic classes and functions. It's part of Kotlin's variance system, which helps manage subtyping relationships between generic types.

Introduction to Contravariance

Contravariance is one of three variance behaviors in Kotlin (alongside covariance and invariance). Put simply, contravariance is the opposite of covariance and allows you to use a more general (supertype) where a more specific type is expected.

In Kotlin, contravariance is declared using the in modifier before a type parameter.

Understanding the "in" Modifier

The in keyword indicates that a type parameter can only be used as an input (consumed) but not as an output (produced).

Here's how contravariance is declared:

kotlin
// A contravariant interface with type parameter T
interface Consumer<in T> {
fun consume(item: T)
}

The in modifier tells the compiler that instances of Consumer<SuperType> can be used wherever a Consumer<SubType> is expected.

Contravariance in Action

Let's see a concrete example to understand how contravariance works:

kotlin
// Define a simple class hierarchy
open class Animal {
open fun makeSound() {
println("Some animal sound")
}
}

class Dog : Animal() {
override fun makeSound() {
println("Woof!")
}
}

class Cat : Animal() {
override fun makeSound() {
println("Meow!")
}
}

// Define a contravariant interface
interface AnimalConsumer<in T> {
fun feed(animal: T)
fun pet(animal: T)
}

// Implement the interface for Animal
class GeneralAnimalCarer : AnimalConsumer<Animal> {
override fun feed(animal: Animal) {
println("Feeding an animal")
animal.makeSound()
}

override fun pet(animal: Animal) {
println("Petting an animal")
animal.makeSound()
}
}

fun main() {
// This is where contravariance works its magic
val dogCarer: AnimalConsumer<Dog> = GeneralAnimalCarer()
val dog = Dog()

// We can use a GeneralAnimalCarer as a Dog-specific carer
dogCarer.feed(dog) // Works fine!
dogCarer.pet(dog) // Works fine!
}

Output:

Feeding an animal
Woof!
Petting an animal
Woof!

In the example above:

  1. We have Animal as a superclass with Dog and Cat as subclasses
  2. We define a contravariant interface AnimalConsumer<in T>
  3. GeneralAnimalCarer implements AnimalConsumer<Animal>
  4. We can use GeneralAnimalCarer as an AnimalConsumer<Dog>

This works because if a class can handle any Animal, it's certainly capable of handling just Dog instances. This is the essence of contravariance.

Why Contravariance Makes Sense

To understand why contravariance is logical, think of it in terms of substitutability:

  • If a function expects a Consumer<Dog>, what would it pass to that consumer? A Dog.
  • If we provide a Consumer<Animal> instead, is that safe? Yes! Because a Consumer<Animal> can handle any animal, including a dog.

This relationship can be counterintuitive at first, but remember this rule: if a type parameter is only used as input (consumed), it can be contravariant.

Real-World Applications

Example 1: Comparators

A common use case for contravariance is with comparators:

kotlin
// A contravariant comparator
interface Comparator<in T> {
fun compare(a: T, b: T): Int
}

// A comparator for any number
class NumberComparator : Comparator<Number> {
override fun compare(a: Number, b: Number): Int {
return a.toDouble().compareTo(b.toDouble())
}
}

fun main() {
// We can use the NumberComparator as an Integer-specific comparator
val integerComparator: Comparator<Int> = NumberComparator()

println(integerComparator.compare(5, 10)) // -1 (5 is less than 10)
println(integerComparator.compare(10, 5)) // 1 (10 is greater than 5)
println(integerComparator.compare(5, 5)) // 0 (equal)
}

Output:

-1
1
0

Example 2: Event Listeners

Another practical example is with event listeners:

kotlin
// Base event class
open class Event(val timestamp: Long)

// More specific event types
class ClickEvent(val x: Int, val y: Int, timestamp: Long) : Event(timestamp)
class KeyEvent(val keyCode: Int, timestamp: Long) : Event(timestamp)

// Contravariant event handler
interface EventListener<in E : Event> {
fun onEvent(event: E)
}

// A listener for all events
class GeneralEventLogger : EventListener<Event> {
override fun onEvent(event: Event) {
println("Event logged at ${event.timestamp}")
}
}

fun main() {
// We can use the general logger as a specific event listener
val clickListener: EventListener<ClickEvent> = GeneralEventLogger()
val keyListener: EventListener<KeyEvent> = GeneralEventLogger()

clickListener.onEvent(ClickEvent(10, 20, System.currentTimeMillis()))
keyListener.onEvent(KeyEvent(65, System.currentTimeMillis())) // 'A' key
}

Output:

Event logged at 1628097433262
Event logged at 1628097433263

When to Use Contravariance

Use contravariance when:

  1. Your generic type only consumes values of the type parameter (like parameters to functions)
  2. You want to allow the use of more general (supertype) implementations where more specific ones are expected

Remember the mnemonic: IN = Consumer (the type parameter is consumed)

Contravariance vs. Covariance vs. Invariance

Let's compare the three variance annotations in Kotlin:

Variance TypeAnnotationDescriptionUse Case
Contravariancein TAccepts supertypesWhen T is only consumed
Covarianceout TAccepts subtypesWhen T is only produced
InvarianceNo annotationExact type matchWhen T is both consumed and produced

Type Projection with Contravariance

Besides declaring a class as contravariant, you can also use contravariant type projections for individual functions:

kotlin
class BasicContainer<T>(var item: T)

fun processItems(container: BasicContainer<in Int>) {
container.item = 42 // This is safe because 'in Int' means container can hold Int or any supertype
}

fun main() {
val numberContainer = BasicContainer<Number>(0.0)
processItems(numberContainer) // This works because of the 'in' projection
println(numberContainer.item) // Output: 42
}

Output:

42

Common Mistakes and Issues

Mistake 1: Using a Contravariant Type as Output

kotlin
interface Problematic<in T> {
fun process(item: T) // This is fine
fun get(): T // Compilation error: Type parameter T is declared as 'in' but occurs in 'out' position
}

Mistake 2: Mixing Variance Annotations

kotlin
// This won't compile because T is used in both in and out positions
interface MixedUsage<in T> {
fun consume(item: T) // in position (ok)
fun produce(): T // out position (error!)
}

Summary

Contravariance in Kotlin allows you to use more general types where specific ones are expected, which is particularly useful for consumers of data. The key features are:

  • Mark type parameters with the in modifier to make them contravariant
  • Contravariant type parameters can only be used as inputs (consumed), not outputs
  • A type Consumer<SuperType> can be used wherever a Consumer<SubType> is needed
  • Contravariance is appropriate for parameters that only serve as inputs to functions

Understanding contravariance improves the flexibility and type safety of your Kotlin code, particularly when working with hierarchies of types and APIs that consume data.

Further Exercises

  1. Implement a contravariant Printer<in T> interface that can print different types of objects.
  2. Create a validation system with a contravariant Validator<in T> interface.
  3. Implement a Filter<in T> interface and create implementations that can filter different types in a type-safe manner.
  4. Create a contravariant event handler for UI events (e.g., mouse events, keyboard events, etc.).

Additional Resources



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)