Skip to content

Kotlin Generics — Type Parameters and Variance Explained

DodaTech Updated 2026-06-28 10 min read

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 works with any type while ensuring you can only add matching elements. Kotlin's declaration-site variance with in and out is cleaner than Java's use-site wildcards. Reified type parameters allow inspecting generic types at runtime, something Java cannot do. Mastering generics is essential for writing libraries, reusable components, and type-safe APIs.

Real-World Use

DodaTech uses generic Repository interfaces for all data access layers, allowing the same CRUD operations for User, Product, and Log entities. Generic network clients handle different API response types with compile-time safety. Reified type parameters enable JSON deserialization without passing Class arguments.

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

  1. Using raw types (Java-style): Kotlin does not allow raw generic types. Always specify type arguments or use star projection.

  2. Confusing in and out: out means the generic is covariant (production, read). in means contravariant (consumption, write). Remember: out for output, in for input.

  3. 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.

  4. 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.

  5. Using star projection when actual type is known: Star projection loses type information. If you know the type, use it explicitly.

  6. Forgetting that reified only works with inline functions: Only inline functions can have reified type parameters. Regular functions cannot.

Practice Questions

  1. What is the difference between covariant (out) and contravariant (in)?

Answer: out (covariant) allows a generic type to be a producer of T. List is a subtype of List. in (contravariant) allows a generic type to be a consumer of T. Comparable is a subtype of Comparable.

  1. 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.

  1. 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.

  1. 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.

  1. 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

Does Kotlin have type erasure like Java?

Yes, for non-reified type parameters. Type arguments are erased at runtime unless the function is inline with reified type parameters.

What is the difference between Array and List in generics?

Array is invariant in Kotlin. List is covariant (effectively List). Array is not a subtype of Array, but List is a subtype of List.

Can I use primitives as type arguments?

Yes. Kotlin maps primitives to their boxed types: Int, Long, Double, etc. The compiler optimizes where possible.

What does the Nothing type mean in generics?

Nothing is a subtype of all types. It represents values that never exist. Used in sealed class hierarchies and functions that always throw.

Can I have multiple type parameters?

Yes. Separate them with commas: 'class Map<K, V>'. Kotlin supports up to 22 type parameters.

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