Cracking Kotlin's Type Projections: A Guide to Flexible Generics

 Master Covariance, Contravariance, and Star Projections to write safer, more reusable code.

Kotlin's Type Projections

Generics in Kotlin are incredibly powerful, but they can also be stubborn. Have you ever tried to pass a List<String> into a function expecting a List<Any> and wondered why the compiler complained? Or tried to treat a specialized manager as a general one?

This is the Invariance Challenge, and the solution lies in mastering Type Projections.

The Invariant Challenge: Why Generics Are “Stubborn”

In Kotlin, generic classes are invariant by default. This means that even if Book is a subtype of MediaLibrarian<Book> is not a subtype of Librarian<Media>.

open class Media
class Book : Media()
class Movie : Media()

class Librarian<T : Media> {
private val items = mutableListOf<T>()

fun addMedia(item: T) { // T in "in" position (input)
items.add(item)
}

fun getMedia(index: Int): T? { // T in "out" position (output)
return items.getOrNull(index)
}
}

Why is this restricted? Imagine if the compiler allowed this:

val bookLibrarian = Librarian<Book>()
val generalLibrarian: Librarian<Media> = bookLibrarian // ❌ Error!
generalLibrarian.addMedia(Movie()) // CRASH: You just put a Movie into a Book-only list!

To prevent this “pollution” of typed collections, Kotlin forces invariance. But sometimes, we need flexibility.

Type Projections: Precision at the Point of Use

Instead of changing the class itself, we use Type Projections to restrict the type’s capabilities at the use-site in exchange for subtyping flexibility.

1. out Projections (Covariance)

When you only need to read from a generic, use out.

fun displayMediaCollection(librarian: Librarian<out Media>) {
// ✅ ALLOWED: We can get items out as 'Media'
val item: Media? = librarian.getMedia(0)

// ❌ FORBIDDEN: addMedia(T) now expects 'Nothing'
// librarian.addMedia(Book())
}

The Rule: When you project out T, the compiler treats the input positions as Nothing. Since you can't create an instance of Nothing, you can't call functions that take T as a parameter. This makes it safe to treat a Librarian<Book> as a Librarian<out Media>.

2. in Projections (Contravariance)

When you only need to write to a generic, use in.

class ContentProcessor<T : Media> {
fun process(item: T) { println("Processing...") }
fun getLastResult(): T? = null
}

fun acceptBookProcessor(processor: ContentProcessor<in Book>) {
// ✅ ALLOWED: You can safely put a Book in.
processor.process(Book())

// ⚠️ RESTRICTED: Return type becomes Any?
val result: Any? = processor.getLastResult()
}

The Nuance: From the caller’s perspective, the return type of getLastResult() "erases" to Any?. Even if the function internally returns a Media, the compiler can no longer guarantee the specific type, so it defaults to the safest common ancestor.

3. Star Projections (*)

The star projection is the “I don’t care” operator. It’s used when the specific type argument is irrelevant to the logic.

fun printCollectionSize(librarian: Librarian<*>) {
// Internally, Librarian<*> is treated as Librarian<out Media>
val item: Media? = librarian.getMedia(0)
println("Item 1 is $item")
}

Technically speaking: For a type Librarian<T : Media>Librarian<*> is equivalent to Librarian<out Media> for reads and Librarian<in Nothing> for writes.

Real-World Analogy: The Parcel Service

  • ParcelService<out Item> (The Courier): Their job is to deliver items to you. You know you'll receive an Item, but you can't hand them a new item to ship because this specific route is "outbound only."
  • ParcelService<in Letter> (The Drop-Box): This service is for accepting mail. You can drop a Letter in. You don't know what's already inside the box (it might return Any? junk), but you know it’s safe to put your letter in.
  • ParcelService<*> (The Inspector): They are just checking if the box is full. They don't need to put anything in, and if they take something out, they just treat it as a generic "object."

Clarity Over Cleverness

Type projections are powerful, but they add cognitive load. My rule of thumb: Use Declaration-Site Variance (putting in/out in the class header) whenever possible. Use Type Projections (use-site) only when you are dealing with an invariant class (like Array or MutableList) that you don't own or that must remain invariant for other reasons.

Frequently Asked Questions (FAQs)

Why does the return type of an in projection become Any? specifically?

Because an in projection allows for contravariance. If you have a Processor<in Book>, it could actually be a Processor<Media>. Since a Processor<Media> might return a Movie, the only type the compiler can be 100% sure of is the top-level Any?.

Is List<*> the same as List<Any?>?

Not quite. List<out Any?> (which is what List<*> effectively becomes if there is no upper bound) allows you to read items as Any?. However, MutableList<*> is very different from MutableList<Any?>. You can add an integer to a MutableList<Any?>, but you cannot add anything to a MutableList<*>.

When should I use out vs in?

Remember the acronym PECSProducer Extends (out), Consumer Super (in). If your class produces values of type T, use out. If it consumes them, use in.

Relevant Questions to the Viewers

  • Have you ever encountered a Type Mismatch error that was solved by adding out?
  • Why do you think Array in Kotlin is invariant while List is covariant? (Hint: Think about mutability!)
  • Can you think of a scenario where in projections are absolutely necessary?

Comments

Popular posts from this blog

Stop Writing Massive when Statements: Master the State Pattern in Kotlin

Coroutines & Flows: 5 Critical Anti-Patterns That Are Secretly Slowing Down Your Android App

Code Generation vs. Reflection: A Build-Time Reliability Analysis