Generics let you write code that works with any type while keeping full type safety. You’ve already used them — List<String>, Flow<Int>, StateFlow<UiState>. But Kotlin’s generics go beyond basic type parameters. With out and in variance modifiers, you control how generic types relate to each other. With reified, you access type information at runtime — something Java generics can’t do. This guide covers everything from basic generics to advanced variance and reified type parameters, with practical Android examples throughout.
Generic Basics
Generic classes
// A box that holds any type
class Box<T>(val item: T)
val stringBox = Box("Hello") // Box<String> — type inferred
val intBox = Box(42) // Box<Int>
val userBox = Box<User>(user) // explicit type
println(stringBox.item) // "Hello" — compiler knows this is String
println(intBox.item + 1) // 43 — compiler knows this is Int
// Multiple type parameters
class Pair<A, B>(val first: A, val second: B)
val pair = Pair("Alice", 25) // Pair<String, Int>
Generic functions
// Function that works with any type
fun <T> singletonList(item: T): List<T> {
return listOf(item)
}
val strings = singletonList("Hello") // List<String>
val numbers = singletonList(42) // List<Int>
// Generic extension function
fun <T> List<T>.secondOrNull(): T? {
return if (size >= 2) this[1] else null
}
val second = listOf("a", "b", "c").secondOrNull() // "b"
Generic constraints — upper bounds
// T must be Comparable
fun <T : Comparable<T>> maxOf(a: T, b: T): T {
return if (a > b) a else b
}
maxOf(3, 7) // 7 — Int is Comparable
maxOf("apple", "banana") // "banana" — String is Comparable
// maxOf(listOf(1), listOf(2)) // ❌ compile error — List is not Comparable
// Multiple upper bounds with where clause
fun <T> process(item: T) where T : Serializable, T : Comparable<T> {
// T must implement BOTH Serializable and Comparable
println("${item.compareTo(item)}")
}
Variance — The Problem
In Kotlin, List<Dog> is not a subtype of List<Animal> by default, even though Dog is a subtype of Animal. This is called invariance:
open class Animal
class Dog : Animal()
class Cat : Animal()
// Dog is a subtype of Animal
val animal: Animal = Dog() // ✅ works
// But MutableList<Dog> is NOT a subtype of MutableList<Animal>
fun addAnimal(list: MutableList<Animal>) {
list.add(Cat()) // adds a Cat
}
val dogs: MutableList<Dog> = mutableListOf(Dog(), Dog())
// addAnimal(dogs) // ❌ compile error — and for good reason!
// If this worked, we'd have a Cat in a MutableList<Dog>!
// This is why generic types are INVARIANT by default — for safety
But sometimes you want this subtyping relationship. That’s where out and in come in.
out — Covariance (Producer)
The out modifier makes a generic type covariant — meaning Box<Dog> becomes a subtype of Box<Animal>. The trade-off: you can only produce (return) values of type T, never consume (accept) them:
// out T = "I only produce T, never consume it"
class Producer<out T>(private val item: T) {
fun get(): T = item // ✅ returning T is allowed
// fun set(value: T) { } // ❌ compile error — consuming T not allowed
}
val dogProducer: Producer<Dog> = Producer(Dog())
val animalProducer: Producer<Animal> = dogProducer // ✅ covariant!
// Safe because animalProducer can only PRODUCE values
// It returns Dog, which IS an Animal — no problem
// Real Kotlin example: List is declared as List<out E>
val dogs: List<Dog> = listOf(Dog(), Dog())
val animals: List<Animal> = dogs // ✅ works because List uses out
// List only produces elements (get), never consumes them (no add)
out in practice
// Without out — can't pass List<Dog> where List<Animal> is expected
fun printAnimals(animals: List<Animal>) { // List already uses out
animals.forEach { println(it) }
}
val dogs: List<Dog> = listOf(Dog(), Dog())
printAnimals(dogs) // ✅ works because List<out E>
// Your own covariant type
interface DataSource<out T> {
fun getAll(): List<T> // ✅ produces T
fun getById(id: String): T // ✅ produces T
// fun save(item: T) // ❌ would consume T — not allowed
}
val dogSource: DataSource<Dog> = DogRepository()
val animalSource: DataSource<Animal> = dogSource // ✅ covariant
in — Contravariance (Consumer)
The in modifier makes a generic type contravariant — the opposite of out. Handler<Animal> becomes a subtype of Handler<Dog>. The trade-off: you can only consume (accept) values of type T, never produce (return) them:
// in T = "I only consume T, never produce it"
class Consumer<in T> {
fun process(item: T) { } // ✅ consuming T is allowed
// fun get(): T { } // ❌ compile error — producing T not allowed
}
val animalConsumer: Consumer<Animal> = Consumer()
val dogConsumer: Consumer<Dog> = animalConsumer // ✅ contravariant!
// Safe because dogConsumer only CONSUMES values
// It accepts Animal, so accepting Dog (a subtype) is fine
// Real Kotlin example: Comparable is declared as Comparable<in T>
// If you can compare Animals, you can certainly compare Dogs
val animalComparator: Comparable<Animal> = object : Comparable<Animal> {
override fun compareTo(other: Animal): Int = 0
}
val dogComparator: Comparable<Dog> = animalComparator // ✅ contravariant
in in practice
// Event handler that consumes events
interface EventHandler<in E> {
fun handle(event: E) // ✅ consumes E
// fun getLastEvent(): E // ❌ would produce E — not allowed
}
class AnimalEventHandler : EventHandler<Animal> {
override fun handle(event: Animal) {
println("Handling animal event")
}
}
// Can use AnimalEventHandler where DogEventHandler is expected
val dogHandler: EventHandler<Dog> = AnimalEventHandler() // ✅
dogHandler.handle(Dog()) // AnimalEventHandler can handle any Animal, including Dog
// Comparator uses in — Comparator<in T>
val animalComparator = Comparator<Animal> { a, b -> 0 }
val dogs = listOf(Dog(), Dog())
dogs.sortedWith(animalComparator) // ✅ Animal comparator works for Dogs
Declaration-Site vs Use-Site Variance
// DECLARATION-SITE variance — defined on the class itself
// Applies everywhere this class is used
interface Source<out T> { // always covariant
fun next(): T
}
interface Sink<in T> { // always contravariant
fun put(item: T)
}
// USE-SITE variance — defined where you use it (like Java wildcards)
// Applies only in that specific context
class Box<T>(var item: T) // invariant by default
// Use out at the call site to make it covariant temporarily
fun copyOut(from: Box<out Animal>) {
val animal: Animal = from.item // ✅ can read
// from.item = Dog() // ❌ can't write — out projection
}
// Use in at the call site to make it contravariant temporarily
fun copyIn(to: Box<in Dog>) {
to.item = Dog() // ✅ can write
// val dog: Dog = to.item // ❌ can't read as Dog — in projection
}
val dogBox = Box(Dog())
val animalBox = Box<Animal>(Cat())
copyOut(dogBox) // ✅ Box<Dog> works as Box<out Animal>
copyIn(animalBox) // ✅ Box<Animal> works as Box<in Dog>
Star Projection — *
When you don’t know or don’t care about the type argument, use *:
// * means "I don't know the type"
fun printAll(list: List<*>) {
list.forEach { println(it) } // elements are Any?
}
printAll(listOf(1, 2, 3))
printAll(listOf("a", "b"))
// Star projection rules:
// For out types: Foo<out T> → Foo<*> means Foo<out Any?> (can read Any?)
// For in types: Foo<in T> → Foo<*> means Foo<in Nothing> (can't write anything)
// For invariant: Foo<T> → Foo<*> means Foo<out Any?> for reads
// Practical use — checking type without caring about generic parameter
fun isListType(obj: Any): Boolean {
return obj is List<*> // can check List but not List<String> at runtime
}
reified Type Parameters
In Java and Kotlin, generic type information is erased at runtime. You can’t check if (x is List<String>) because String is gone at runtime. The reified keyword fixes this for inline functions:
// Without reified — can't access T at runtime
fun <T> isType(value: Any): Boolean {
// return value is T // ❌ compile error — T is erased at runtime
return false
}
// With reified — T is available at runtime
inline fun <reified T> isType(value: Any): Boolean {
return value is T // ✅ works! T is preserved at runtime
}
println(isType<String>("Hello")) // true
println(isType<Int>("Hello")) // false
println(isType<List<*>>(listOf(1))) // true
Why inline is required
// reified only works with inline functions because:
// inline functions are COPIED to the call site at compile time
// So the compiler knows the actual type and can insert the type check
// At the call site:
isType<String>("Hello")
// Compiler inlines it as:
"Hello" is String // no generics involved — concrete type check
reified in practice — common patterns
// 1. Type-safe casting
inline fun <reified T> Any.asOrNull(): T? {
return this as? T
}
val name: String? = someObject.asOrNull<String>()
// 2. Gson/Moshi deserialization without Class parameter
inline fun <reified T> Gson.fromJson(json: String): T {
return fromJson(json, T::class.java) // access T::class at runtime
}
val user = gson.fromJson<User>(jsonString) // no User::class.java needed!
// 3. Starting an Activity without Class parameter
inline fun <reified T : Activity> Context.startActivity() {
startActivity(Intent(this, T::class.java))
}
startActivity<DetailActivity>() // clean and type-safe
// 4. Getting a ViewModel
inline fun <reified T : ViewModel> Fragment.viewModel(): T {
return ViewModelProvider(this)[T::class.java]
}
val viewModel = viewModel<ArticleViewModel>()
// 5. Filtering by type from a collection
inline fun <reified T> List<*>.filterIsInstanceTyped(): List<T> {
return filterIsInstance<T>()
}
val strings = mixedList.filterIsInstanceTyped<String>()
Type Erasure — What You Can’t Do
// Generic type info is ERASED at runtime (except with reified)
// ❌ Can't check generic type at runtime
fun checkType(list: Any) {
// if (list is List<String>) { } // compile error — erased
if (list is List<*>) { } // ✅ can check raw type only
}
// ❌ Can't create instances of generic type
fun <T> createInstance(): T {
// return T() // compile error — don't know what T is at runtime
throw UnsupportedOperationException()
}
// ✅ With reified, you can access the class
inline fun <reified T> createInstance(): T {
return T::class.java.getDeclaredConstructor().newInstance() // ✅ works
}
// ❌ Can't use generic type in when expressions
fun <T> process(value: T) {
// when (T) { } // compile error
}
// ✅ With reified, you can
inline fun <reified T> process(value: Any) {
when (T::class) {
String::class -> println("It's a String")
Int::class -> println("It's an Int")
}
}
Real Android Patterns
Generic Result wrapper
sealed class Result<out T> {
data class Success<out T>(val data: T) : Result<T>()
data class Error(val exception: Throwable, val message: String? = null) : Result<Nothing>()
data object Loading : Result<Nothing>()
}
// out T means Result<Dog> is a subtype of Result<Animal>
// Nothing is a subtype of everything — Error and Loading work for any Result<T>
// Usage in repository
suspend fun <T> safeApiCall(call: suspend () -> T): Result<T> {
return try {
Result.Success(call())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.Error(e, e.message)
}
}
// Usage in ViewModel
val result: Result<List<Article>> = safeApiCall { api.getArticles() }
when (result) {
is Result.Success -> showArticles(result.data)
is Result.Error -> showError(result.message)
is Result.Loading -> showLoading()
}
Generic RecyclerView adapter
abstract class GenericAdapter<T, VH : RecyclerView.ViewHolder>(
private val diffCallback: DiffUtil.ItemCallback<T>
) : ListAdapter<T, VH>(diffCallback) {
override fun onBindViewHolder(holder: VH, position: Int) {
bind(holder, getItem(position))
}
abstract fun bind(holder: VH, item: T)
}
// Concrete implementation
class ArticleAdapter : GenericAdapter<Article, ArticleViewHolder>(ArticleDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
val binding = ItemArticleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ArticleViewHolder(binding)
}
override fun bind(holder: ArticleViewHolder, item: Article) {
holder.bind(item)
}
}
Generic repository with caching
class CachedRepository<T>(
private val fetchFromNetwork: suspend () -> T,
private val fetchFromCache: suspend () -> T?,
private val saveToCache: suspend (T) -> Unit
) {
private val _data = MutableStateFlow<Result<T>>(Result.Loading)
val data: StateFlow<Result<T>> = _data
suspend fun load(forceRefresh: Boolean = false) {
if (!forceRefresh) {
fetchFromCache()?.let {
_data.value = Result.Success(it)
}
}
try {
val fresh = fetchFromNetwork()
saveToCache(fresh)
_data.value = Result.Success(fresh)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (_data.value !is Result.Success) {
_data.value = Result.Error(e)
}
}
}
}
// Usage
val articleRepo = CachedRepository(
fetchFromNetwork = { api.getArticles() },
fetchFromCache = { dao.getArticles() },
saveToCache = { articles -> dao.insertAll(articles) }
)
reified utility functions for Android
// Intent extras with type safety
inline fun <reified T : Serializable> Intent.getSerializableExtraCompat(key: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getSerializableExtra(key, T::class.java)
} else {
@Suppress("DEPRECATION")
getSerializableExtra(key) as? T
}
}
val userId = intent.getSerializableExtraCompat<String>("user_id")
// Fragment arguments with reified
inline fun <reified T : Fragment> newInstance(vararg args: Pair<String, Any>): T {
val fragment = T::class.java.getDeclaredConstructor().newInstance()
fragment.arguments = bundleOf(*args)
return fragment
}
val fragment = newInstance<ArticleFragment>("id" to "123")
// SharedPreferences with reified
inline fun <reified T> SharedPreferences.get(key: String, default: T): T {
return when (T::class) {
String::class -> getString(key, default as String) as T
Int::class -> getInt(key, default as Int) as T
Boolean::class -> getBoolean(key, default as Boolean) as T
Float::class -> getFloat(key, default as Float) as T
Long::class -> getLong(key, default as Long) as T
else -> throw IllegalArgumentException("Unsupported type: ${T::class}")
}
}
val name = prefs.get<String>("name", "")
val age = prefs.get<Int>("age", 0)
Common Mistakes to Avoid
Mistake 1: Using out when you need to consume
// ❌ Can't add items to an out-projected type
fun addDog(list: MutableList<out Animal>) {
// list.add(Dog()) // compile error — out means read-only
}
// ✅ Use in if you need to write, or remove variance
fun addDog(list: MutableList<in Dog>) {
list.add(Dog()) // ✅ works
}
// ✅ Or use invariant type if you need both read and write
fun addDog(list: MutableList<Dog>) {
list.add(Dog())
val dog = list[0]
}
Mistake 2: Forgetting that type info is erased
// ❌ Unchecked cast — type argument is erased
fun <T> unsafeCast(value: Any): T {
return value as T // warning: unchecked cast — T is erased
}
val result = unsafeCast<String>(42) // ClassCastException at usage, not here!
// ✅ Use reified for safe type checks
inline fun <reified T> safeCast(value: Any): T? {
return value as? T // ✅ safe cast with actual type check
}
val result = safeCast<String>(42) // null — safe
Mistake 3: Overcomplicating variance
// ❌ Adding variance where it's not needed
class SimpleBox<out T>(val item: T) // out is fine here but...
// Now you can't add a method that takes T
// class SimpleBox<out T>(val item: T) {
// fun replace(newItem: T) { } // ❌ can't consume T
// }
// ✅ Keep it invariant if you need both read and write
class SimpleBox<T>(var item: T) {
fun replace(newItem: T) {
item = newItem // ✅ works — invariant allows both
}
}
// Add variance only when you specifically need subtyping relationships
Mistake 4: Using reified without understanding inline implications
// ❌ Large inline function with reified — bloats bytecode
inline fun <reified T> heavyProcessing(data: List<Any>): List<T> {
// 50 lines of code here...
// ALL of this gets copied to every call site
return data.filterIsInstance<T>()
}
// ✅ Keep inline functions small, extract non-reified logic
inline fun <reified T> filterByType(data: List<Any>): List<T> {
return data.filterIsInstance<T>() // small — only the reified part
}
fun processFiltered(data: List<Any>): List<String> {
val filtered = filterByType<String>(data) // inlined — small
return heavyProcess(filtered) // not inlined — normal call
}
Summary
- Generics let you write type-safe, reusable code with type parameters like
<T> - Use upper bounds (
<T : Comparable<T>>) to constrain what types are allowed outmakes a type covariant (producer) —Box<Dog>is a subtype ofBox<Animal>inmakes a type contravariant (consumer) —Handler<Animal>is a subtype ofHandler<Dog>- Remember: out = produce, in = consume — the modifier restricts what you can do with T
- Declaration-site variance (
class Foo<out T>) applies everywhere; use-site variance (Foo<out T>at call site) applies locally - Star projection (
*) means “I don’t know the type” — useful for type-agnostic operations - Type erasure removes generic type info at runtime — you can’t check
is List<String> reifiedpreserves type info at runtime in inline functions — enablesis T,T::class, and type-safe APIs- Use
reifiedfor JSON parsing, Intent extras, Activity navigation, ViewModel creation - Kotlin’s
ListisList<out E>(covariant),ComparableisComparable<in T>(contravariant) - Keep inline reified functions small to avoid bytecode bloat
Generics with variance and reified type parameters are what separate intermediate Kotlin from advanced Kotlin. Once out and in click — “out produces, in consumes” — you’ll design cleaner APIs, write safer code, and understand why the Kotlin standard library is structured the way it is. And reified is one of Kotlin’s genuine superpowers over Java — use it to eliminate boilerplate wherever type information matters at runtime.
Happy coding!
Comments (0)