๐ŸŸฃ Kotlin Programming

Kotlin Generics and Reified Types

Generics let you write one piece of code that works safely with many types: a Result wrapper, a cache, a paging helper. Variance and type erasure confuse many developers. This guide explains them with Android examples, and shows how reified types solve erasure for inline functions.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Advanced
On this page
  1. Type parameters and constraints
  2. Variance: in and out
  3. Star projection
  4. Type erasure
  5. Reified type parameters
  6. A practical generic: an in-memory cache
  7. Common mistakes
  8. Frequently asked questions
  9. Conclusion

You've used generics since your first List<String>. Writing your own is a different matter: why can't you pass a List<Dog> where a MutableList<Animal> is expected? Why does T::class fail inside a function? This guide answers those questions with code you'd actually write in an Android app.

Type parameters and constraints

A typed result wrapperKotlin
sealed interface Outcome<out T> {
    data class Success<T>(val value: T) : Outcome<T>
    data class Failure(val error: Throwable) : Outcome<Nothing>
}

inline fun <T, R> Outcome<T>.map(transform: (T) -> R): Outcome<R> = when (this) {
    is Outcome.Success -> Outcome.Success(transform(value))
    is Outcome.Failure -> this
}

suspend fun <T> runCatchingIo(block: suspend () -> T): Outcome<T> =
    try { Outcome.Success(block()) } catch (e: IOException) { Outcome.Failure(e) }

Failure uses Nothing, the type with no values and a subtype of everything. Thanks to out T (covariance, explained next), an Outcome<Nothing> is usable as an Outcome<User>.

Upper-bound constraintsKotlin
fun <T : Comparable<T>> List<T>.secondLargest(): T? = sortedDescending().getOrNull(1)

// Multiple bounds with where
fun <T> persist(item: T) where T : Parcelable, T : Identifiable { /* ... */ }

Variance: in and out

Variance answers a question: if Dog is a subtype of Animal, is Box<Dog> a subtype of Box<Animal>? It depends on whether the box produces or consumes values.

Covariance with out and contravariance with in With out T, a producer of Dog can be used as a producer of Animal, because reading a Dog as an Animal is safe. With in T, a consumer of Animal can be used as a consumer of Dog, because anything that accepts any Animal accepts a Dog. out T: producer (covariant) List<Dog> โ†’ usable as List<Animal> T only returned, never accepted e.g. List<out E>, Flow<out T> in T: consumer (contravariant) Comparator<Animal> โ†’ usable as Comparator<Dog> T only accepted, never returned e.g. Comparable<in T>, (T) -> Unit
"Producer out, consumer in." A MutableList does both, so it is invariant: MutableList<Dog> is not a MutableList<Animal>.
Why MutableList is invariantKotlin
val dogs: MutableList<Dog> = mutableListOf(Dog())
// val animals: MutableList<Animal> = dogs     // โŒ compile error, and here's why:
// animals.add(Cat())                          // would put a Cat into a list of Dogs!

val readOnly: List<Animal> = dogs              // โœ… List<out E> only produces: safe

Kotlin uses declaration-site variance (interface Source<out T>). You can also use use-site variance (fun copy(from: Array<out Any>, to: Array<Any>)) for classes you don't control.

Star projection

List<*> means "a list of some specific but unknown type". You can read elements as Any? but can't add anything. It's useful when you only need type-independent operations, such as size.

Type erasure

On the JVM and Android, generic type arguments are erased at runtime: a List<String> and a List<Int> are both just List. So this doesn't compile:

ErasureKotlin
fun <T> isOfType(value: Any): Boolean = value is T     // โŒ Cannot check for instance of erased type: T

Reified type parameters

In an inline function, the compiler copies the body to each call site, where the concrete type is known. Mark the parameter reified and you can use T at runtime:

Reified helpers in AndroidKotlin
inline fun <reified T> Any.isOfType(): Boolean = this is T

// Start an Activity without passing ::class.java
inline fun <reified A : Activity> Context.startActivity(configure: Intent.() -> Unit = {}) {
    startActivity(Intent(this, A::class.java).apply(configure))
}
// context.startActivity<SettingsActivity> { putExtra("tab", "privacy") }

// Filter a mixed feed by type
inline fun <reified T> List<FeedItem>.only(): List<T> = filterIsInstance<T>()

// Decode JSON without passing a serializer explicitly (kotlinx.serialization)
inline fun <reified T> String.decode(json: Json = Json { ignoreUnknownKeys = true }): T =
    json.decodeFromString<T>(this)

Libraries use this widely: Hilt's hiltViewModel<T>(), Koin's get<T>() and kotlinx.serialization's decodeFromString<T>() all rely on reified generics.

A practical generic: an in-memory cache

TimedCache.ktKotlin
class TimedCache<K : Any, V : Any>(
    private val ttlMillis: Long,
    private val now: () -> Long = { System.currentTimeMillis() },
) {
    private data class Entry<V>(val value: V, val storedAt: Long)
    private val map = mutableMapOf<K, Entry<V>>()
    private val mutex = Mutex()

    suspend fun getOrLoad(key: K, load: suspend (K) -> V): V = mutex.withLock {
        map[key]?.takeIf { now() - it.storedAt < ttlMillis }?.value
            ?: load(key).also { map[key] = Entry(it, now()) }
    }
}

// val profiles = TimedCache<String, Profile>(ttlMillis = 60_000)
// profiles.getOrLoad(userId) { api.getProfile(it) }

Holding the mutex during load keeps it simple but serialises loads. For high concurrency you'd lock per key.

Common mistakes

  • Over-generalising: a BaseRepository<T, ID, DTO, ENTITY> that every feature fights against. Generic code should remove duplication, not create puzzles.
  • Using Any and casts instead of type parameters.
  • Forgetting that reified requires inline, and that inline functions can't be called from Java with the reified behaviour.
  • Unchecked casts (as List<User>) that hide erasure problems until runtime.

Frequently asked questions

What's the difference between T, T? and T : Any?

A type parameter T may itself be nullable unless bounded by T : Any. T? explicitly allows null. Use T : Any when you need non-null values.

Is Kotlin's out the same as Java's ? extends?

Yes in meaning. out T corresponds to ? extends T, and in T to ? super T. Kotlin adds declaration-site variance so you rarely need wildcards.

Do reified functions increase app size?

Inline functions duplicate their body at each call site, so keep them small. R8 also optimises in release builds.

Conclusion

Generics give you reusable, type-safe code. Variance (out for producers, in for consumers) explains which substitutions are safe. Erasure removes type arguments at runtime, and reified parameters in inline functions bring them back where you need them.

Next step: reuse behaviour without inheritance using Kotlin Delegation and Property Delegates.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Kotlin fluency makes everything else in Android easier, from coroutines and Flow to Compose and clean architecture. The fastest way to build that fluency is to write real Kotlin every day and get feedback on it.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp