Kotlin Generics — Type Parameters and Variance Explained
In this tutorial, you will learn about Kotlin Generics. We cover key concepts, practical examples, and best practices to help you master this topic.
Kotlin generics allow classes and functions to operate on types specified as parameters, with declaration-site variance using in and out keywords, reified type parameters in inline functions, and type constraints for bounded type parameters.
What You'll Learn
- Declare generic classes, interfaces, and functions
- Use type parameters with constraints
- Understand declaration-site variance (in and out)
- Use use-site variance (type projections)
- Apply star projections for unknown types
- Use reified type parameters with inline functions
- Implement generic patterns like Repository and Factory
Why It Matters
Generics enable code reuse without sacrificing type safety. A List
Real-World Use
DodaTech uses generic Repository
Learning Path
flowchart LR A[Extensions] --> B[Generics\nYou are here] B --> C[Android Setup] style B fill:#f90,color:#fff
Generic Classes and Interfaces
Generic types are declared with angle brackets after the class name.
class Box<T>(val value: T) {
fun get(): T = value
fun <R> transform(transform: (T) -> R): Box<R> {
return Box(transform(value))
}
}
interface Repository<T> {
fun getById(id: String): T?
fun getAll(): List<T>
fun save(item: T)
fun delete(id: String)
}
class InMemoryRepository<T> : Repository<T> {
private val items = mutableMapOf<String, T>()
override fun getById(id: String): T? = items[id]
override fun getAll(): List<T> = items.values.toList()
override fun save(item: T) { /* store with generated ID */ }
override fun delete(id: String) { items.remove(id) }
}
fun main() {
val stringBox = Box("Hello")
println(stringBox.get()) // Output: Hello
val lengthBox = stringBox.transform { it.length }
println(lengthBox.get()) // Output: 5
val intBox = Box(42)
val doubled = intBox.transform { it * 2 }
println(doubled.get()) // Output: 84
}
Output: Generic classes work with any type. The transform method demonstrates generic methods within generic classes.
Generic Functions
Functions can have their own type parameters independent of the class.
fun <T> List<T>.secondOrNull(): T? {
return if (size >= 2) this[1] else null
}
fun <T, R> List<T>.mapAndFilter(
transform: (T) -> R,
predicate: (R) -> Boolean
): List<R> {
return this.map(transform).filter(predicate)
}
fun <T : Comparable<T>> maxOf(vararg items: T): T? {
return items.maxOrNull()
}
fun main() {
println(listOf(1, 2, 3).secondOrNull()) // Output: 2
println(emptyList<Int>().secondOrNull()) // Output: null
val numbers = listOf(1, 2, 3, 4, 5)
val result = numbers.mapAndFilter(
transform = { it * 2 },
predicate = { it > 5 }
)
println(result) // Output: [6, 8, 10]
println(maxOf(3, 7, 1, 9, 4)) // Output: 9
println(maxOf("apple", "banana", "cherry")) // Output: cherry
}
Output: Generic functions work with type inference at the call site. Type parameters are inferred from arguments.
Type Constraints
Constrain type parameters to ensure they have certain capabilities.
// Upper bound constraint: T must implement Comparable
fun <T : Comparable<T>> sortedDescending(list: List<T>): List<T> {
return list.sortedDescending()
}
// Multiple constraints with where
fun <T> ensureCapacity(list: MutableList<T>, minSize: Int)
where T : Comparable<T>, T : java.io.Serializable {
while (list.size < minSize) {
@Suppress("UNCHECKED_CAST")
list.add(list.maxOrNull() as T)
}
}
// Constraint with multiple bounds
interface JsonSerializable
interface Loggable
class DataProcessor<T> where T : JsonSerializable, T : Loggable {
fun process(item: T) {
// item can use both interfaces
}
}
fun main() {
println(sortedDescending(listOf(3, 1, 4, 1, 5))) // Output: [5, 4, 3, 1, 1]
println(sortedDescending(listOf("c", "a", "b"))) // Output: [c, b, a]
}
Output: Type constraints restrict what types can be used with a generic. The compiler enforces these constraints.
Declaration-Site Variance
Kotlin uses in and out at the declaration site to specify variance.
- out (covariant): Producer. Can only return T, never consume T. Example: List
- in (contravariant): Consumer. Can only consume T, never return T. Example: Comparable
// Covariant: Source produces T (read-only)
interface Source<out T> {
fun get(): T
// fun set(value: T) // Not allowed: T is in 'out' position
}
// Contravariant: Sink consumes T (write-only)
interface Sink<in T> {
fun put(value: T)
// fun get(): T // Not allowed: T is in 'in' position
}
class StringSource(private val value: String) : Source<String> {
override fun get(): String = value
}
class AnySink : Sink<Any> {
override fun put(value: Any) {
println("Stored: $value")
}
}
fun main() {
// Covariance: Source<String> is subtype of Source<Any>
val stringSource: Source<String> = StringSource("Hello")
val anySource: Source<Any> = stringSource // OK: out is covariant
println(anySource.get()) // Output: Hello
// Contravariance: Sink<Any> is subtype of Sink<String>
val anySink: Sink<Any> = AnySink()
val stringSink: Sink<String> = anySink // OK: in is contravariant
stringSink.put("World") // Output: Stored: World
}
Output: out projection makes the generic covariant (subtypes preserve ordering). in projection makes it contravariant (subtypes reverse ordering).
Use-Site Variance (Type Projections)
When you cannot change the declaration, use variance at the call site.
// Immutable container (no variance annotation)
class Container<T>(private val value: T) {
fun get(): T = value
}
// Copy function with use-site variance
fun copyOut(source: Container<out Any>, target: MutableList<Any>) {
target.add(source.get())
}
fun fillIn(source: List<String>, target: Container<in String>) {
// target accepts String
}
fun main() {
val stringContainer = Container("Hello")
val list = mutableListOf<Any>()
// Use-site out projection: read from Container<out Any>
copyOut(stringContainer, list) // Works: projecting to out Any
println(list) // Output: [Hello]
// Star projection: unknown type
val unknown: Container<*> = Container(42)
val value: Any? = unknown.get() // Star projection gives Any?
println(value) // Output: 42
}
Output: Use-site variance projects the generic parameter at the call location, enabling flexibility without changing the class declaration.
Star Projections
Star projection (*) means the type is unknown but in a defined variance position.
fun printLength(list: List<*>) {
println("Size: ${list.size}")
// list[0] is of type Any?
val first: Any? = list.firstOrNull()
println("First: $first")
}
fun describeBox(box: Box<*>) {
val value = box.get()
println("Box contains: $value (type: ${value?.javaClass?.simpleName})")
}
fun main() {
printLength(listOf(1, 2, 3)) // Output: Size: 3 / First: 1
printLength(listOf("a", "b", "c")) // Output: Size: 3 / First: a
printLength(emptyList<Int>()) // Output: Size: 0 / First: null
describeBox(Box("Hello")) // Output: Box contains: Hello (type: String)
describeBox(Box(42)) // Output: Box contains: 42 (type: Integer)
}
Output: Star projections accept any type. The values are read as Any? (nullable safe).
Reified Type Parameters
Reified type parameters retain their type information at runtime, which is erased in Java.
inline fun <reified T> isInstance(value: Any): Boolean {
return value is T // This works because T is reified
}
inline fun <reified T> List<*>.filterIsInstance(): List<T> {
return this.filter { it is T }.map { it as T }
}
inline fun <reified T> createInstance(): T? {
return try {
T::class.java.getDeclaredConstructor().newInstance()
} catch (e: Exception) {
println("Cannot create instance: ${e.message}")
null
}
}
class User(val name: String = "") {
override fun toString() = "User($name)"
}
fun main() {
println(isInstance<String>("Hello")) // Output: true
println(isInstance<Int>("Hello")) // Output: false
val mixed = listOf("a", 1, "b", 2, "c", 3)
val strings: List<String> = mixed.filterIsInstance<String>()
println(strings) // Output: [a, b, c]
val numbers: List<Int> = mixed.filterIsInstance<Int>()
println(numbers) // Output: [1, 2, 3]
}
Output: Reified type parameters work only with inline functions. They enable type checks, instance creation, and class references that would be impossible with erased generics.
Generic Factory Pattern
Generics enable type-safe factory patterns.
interface Factory<T> {
fun create(): T
}
class Service(val name: String)
class ServiceFactory : Factory<Service> {
override fun create(): Service {
return Service("Default")
}
}
inline fun <reified T> autoFactory(): Factory<T> {
return object : Factory<T> {
override fun create(): T {
return T::class.java.getDeclaredConstructor().newInstance()
}
}
}
fun main() {
val serviceFactory = ServiceFactory()
val service = serviceFactory.create()
println(service.name) // Output: Default
// Using reified factory
val stringFactory = autoFactory<String>()
val str = stringFactory.create()
println(str) // Output: (empty string)
}
Output: Generic factories create type-safe instances. Reified factories work with any class that has a no-arg constructor.
Common Mistakes
Using raw types (Java-style): Kotlin does not allow raw generic types. Always specify type arguments or use star projection.
Confusing in and out: out means the generic is covariant (production, read). in means contravariant (consumption, write). Remember: out for output, in for input.
Not using reified when needed: If you need to check the type at runtime, make the function inline and use reified. Without reified, type parameters are erased.
Overcomplicating with variance when simple generics suffice: For most cases, invariant generics without in/out are fine. Add variance only when necessary for subtype relationships.
Using star projection when actual type is known: Star projection loses type information. If you know the type, use it explicitly.
Forgetting that reified only works with inline functions: Only inline functions can have reified type parameters. Regular functions cannot.
Practice Questions
- What is the difference between covariant (out) and contravariant (in)?
Answer: out (covariant) allows a generic type to be a producer of T. List
- What is a reified type parameter and why is it useful?
Answer: A reified type parameter retains its type information at runtime, which is normally erased. It enables type checks (is T), instance creation, and class references within inline functions.
- How do you constrain a type parameter to implement multiple interfaces?
Answer: Use the where clause: fun <T> Process(item: T) where T : InterfaceA, T : InterfaceB.
- What is a star projection and when would you use it?
Answer: Star projection (*) means the generic type argument is unknown. Use it when you only need operations that do not depend on the type, such as reading Any? from a list.
- Challenge: Implement a generic EventBus that supports publishing and subscribing with type-safe event classes. Use reified type parameters for registration and inline functions for dispatching.
Answer:
interface Event
class EventBus {
private val handlers = mutableMapOf<Class<*>, MutableList<(Any) -> Unit>>()
inline fun <reified T : Event> register(noinline handler: (T) -> Unit) {
val type = T::class.java
handlers.getOrPut(type) { mutableListOf() }.add(handler as (Any) -> Unit)
}
@Suppress("UNCHECKED_CAST")
inline fun <reified T : Event> publish(event: T) {
val type = T::class.java
handlers[type]?.forEach { handler ->
handler(event)
}
}
}
class UserLoggedIn(val username: String) : Event()
class DataRefreshed(val source: String) : Event()
fun main() {
val bus = EventBus()
bus.register<UserLoggedIn> { event ->
println("User logged in: ${event.username}")
}
bus.register<DataRefreshed> { event ->
println("Data refreshed from: ${event.source}")
}
bus.publish(UserLoggedIn("alice"))
bus.publish(DataRefreshed("API"))
}
Mini Project
Build a generic type-safe Caching system. Requirements:
- Cache
interface with get, set, remove, clear, and getAll - InMemoryCache
implementation with an internal map - TimedCache
decorator that expires entries after a duration - Generic constraint requiring keys to be hashable
- Reified functions for type-based cache retrieval
- CacheStatistics generic class tracking hits, misses, and size
This project applies generics, variance, constraints, and reified types in a practical caching scenario.
FAQ
What's Next
Now that you understand generics, set up Android development environment or learn coroutines for asynchronous programming. You can also explore collections for more practical generic usage.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro