Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 087 / 100

Kotlin generics: a Android & Kotlin guide for developers

Type-safe reusable code.

Expona Admin8 min read

Kotlin generics: a Android & Kotlin guide for developers

Generics allow us to write code once and reuse it safely across many types without sacrificing type safety. In Android development, we encounter generics constantly—in LiveData, Flow, Room queries, and Retrofit responses—yet many developers treat them as black boxes. This guide walks you through the mechanics of Kotlin generics so you can write more expressive, maintainable code that catches errors at compile time instead of runtime.

Why generics matter in Android

Before diving into syntax, let's establish why generics matter for Android teams. Consider a network response handler: without generics, you'd wrap every response type manually or resort to casting and runtime checks. With generics, you define once and apply everywhere. LiveData<T>, Flow<T>, and Result<T> patterns all lean on generics to give you compile-time safety while keeping code DRY.

When we say type-safe reusable code, we mean the compiler verifies that your List<User> only holds Users, and your API response handler correctly maps JSON to your data class—no surprises in production.

Generic classes and functions

A generic class holds a placeholder for a concrete type that gets filled in when you create an instance. The simplest example is a wrapper:

kotlin
class Result<T>(val value: T) {
    fun get(): T = value
    fun map<R>(transform: (T) -> R): Result<R> = Result(transform(value))
}

Here, T is a type parameter. When you write Result<String>(value = "hello"), the compiler replaces every T with String. The map function introduces a second type parameter R, allowing you to transform a Result<String> into a Result<Int> or any other type.

In real Android projects, you'll see this pattern everywhere:

kotlin
// Retrofit response wrapper
data class ApiResponse<T>(
    val data: T,
    val code: Int,
    val message: String
)

// Room DAO returning wrapped data
@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE id = :id")
    suspend fun getUserById(id: Int): Result<User>
}

Generic functions work the same way. When you need a helper that works across types, declare the type parameter in angle brackets before the function signature:

kotlin
fun <T> parseJson(json: String, clazz: Class<T>): T {
    // Use Gson or kotlinx.serialization
    return gson.fromJson(json, clazz)
}

// Usage
val user: User = parseJson(userJson, User::class.java)
val product: Product = parseJson(productJson, Product::class.java)

Variance: controlling flexibility

Variance determines whether you can pass a subtype where a supertype is expected. This is where generics get subtle. In Kotlin, there are two forms: covariance and contravariance.

Covariance (out)

Use out T when your generic type is only produced (returned), never consumed (accepted as input). This makes the type covariant, meaning you can pass subtypes safely.

kotlin
// Producer: only returns T, never accepts T as input
interface Producer<out T> {
    fun produce(): T
}

// You can now assign Producer<Dog> to Producer<Animal>
val dogProducer: Producer<Dog> = object : Producer<Dog> {
    override fun produce(): Dog = Dog()
}

val animalProducer: Producer<Animal> = dogProducer  // Safe!
val animal: Animal = animalProducer.produce()  // Always a Dog, which is an Animal

In Android, LiveData uses covariance:

kotlin
// LiveData<out T> — you can observe and get values, but not set them
class MyViewModel : ViewModel() {
    private val _user = MutableLiveData<User>()
    val user: LiveData<User> = _user  // LiveData is covariant

    fun setUser(u: User) {
        _user.value = u
    }
}

// This works because LiveData only produces (emits) User
val liveData: LiveData<Any> = viewModel.user

Contravariance (in)

Use in T when your generic type is only consumed (accepted as input), never produced. This makes the type contravariant, allowing you to pass supertypes.

kotlin
// Consumer: only accepts T, never returns T
interface Consumer<in T> {
    fun consume(item: T)
}

// You can assign Consumer<Animal> to Consumer<Dog>
val animalConsumer: Consumer<Animal> = object : Consumer<Animal> {
    override fun consume(item: Animal) {
        println("Consumed: $item")
    }
}

val dogConsumer: Consumer<Dog> = animalConsumer  // Safe!
dogConsumer.consume(Dog())  // Works: Dog is an Animal

In practice, contravariance is less common in Android, but you'll encounter it in callback handlers and event listeners where you want flexibility in what you accept.

Reified types with inline functions

Here's a pain point: at runtime, the JVM erases generic type information. List<String> and List<Int> both become plain List at runtime. This means you can't do if (x is T) inside a generic function—T doesn't exist at runtime.

Kotlin solves this with reified type parameters. Mark your function inline and your type parameter reified, and Kotlin inlines the call, substituting the concrete type:

kotlin
// Without reified — won't compile
fun <T> parseJson(json: String): T {
    return if (json is T) {  // ERROR: Cannot check for instance of erased type
        // ...
    }
}

// With reified — works!
inline fun <reified T> parseJson(json: String): T {
    return gson.fromJson(json, T::class.java)  // T::class works!
}

// Usage
val user = parseJson<User>(userJson)
val product = parseJson<Product>(productJson)

In Android, you'll use reified when working with serialization, dependency injection, or custom view extensions:

kotlin
// Room extension: get typed list without passing Class<T>
inline fun <reified T> Cursor.toModel(): T {
    return gson.fromJson(this.getString(0), T::class.java)
}

// Fragment factory extension
inline fun <reified T : Fragment> newInstance(vararg pairs: Pair<String, Any>): T {
    return T::class.java.newInstance().apply {
        arguments = bundleOf(*pairs)
    }
}

Be careful: inline functions increase bytecode size. Use reified only when you genuinely need runtime type information, not as a convenience.

Type constraints

Sometimes you want to restrict which types can be used as a type parameter. Use where clauses to bound the generic type:

kotlin
// T must be Comparable with itself
fun <T : Comparable<T>> sortList(items: List<T>): List<T> {
    return items.sorted()
}

// Works: String is Comparable<String>
sortList(listOf("c", "a", "b"))  // ["a", "b", "c"]

// Won't compile: User is not Comparable
data class User(val id: Int)
sortList(listOf(User(1), User(2)))  // ERROR

Multiple constraints are possible:

kotlin
fun <T> cache(item: T) where T : Serializable, T : Comparable<T> {
    // item can be serialized and compared
}

// Or use intersection types on parameters
fun <T : Serializable> serialize(item: T): ByteArray where T : Cloneable {
    // ...
}

In Android, you'll often see constraints with View and Context:

kotlin
// Extension that only works on ViewGroup subclasses
fun <T : ViewGroup> T.addChildViews(vararg views: View) {
    views.forEach { this.addView(it) }
}

val layout: LinearLayout = LinearLayout(context)
layout.addChildViews(TextView(context), Button(context))  // Works!

Common mistakes

Mixing up in and out: The most frequent error is forgetting variance rules. If you declare Producer<out T>, you can't pass an item of type T to a function in that interface. Your IDE will catch this, but understanding why helps.

Overusing reified: New developers see reified and think it solves all generic problems. It doesn't. Use it only when you need T::class or is T checks at runtime. The inline cost isn't worth convenience in most cases.

Ignoring type erasure: Don't assume List<String> != List<Int> at runtime—they're the same at the bytecode level. This is why you can't reliably catch ClassCastException from generic collections without care.

Forgetting upper bounds on complex types: When you have nested generics like List<T> and T itself is generic, constrain early to avoid confusion later. For example, <T : Comparator<out User>> is clearer than leaving it open.

Key takeaways

  • Generic classes and functions let you write reusable, type-safe code. Replace all `Any` casts with proper type parameters.
  • Use `out` for producers (functions that return T) and `in` for consumers (functions that accept T). This opens up safe polymorphism.
  • Reach for `reified` only when you need runtime type information in an inline function. The bytecode cost is real.
  • Apply type constraints with `:` syntax to ensure your generic types support the operations you need—Comparable, Serializable, View subclasses, etc.
  • Android's major libraries—LiveData, Flow, Room, Retrofit—all build on generics. Master these patterns and you'll read and write cleaner, safer code across the entire ecosystem.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles