cyberangles guide

Kotlin Generics Explained: A Beginner's Perspective

If you’ve spent any time programming in Kotlin, you’ve likely encountered code like `List<String>`, `Map<Int, User>`, or `fun <T> printItem(item: T)`. These are examples of **generics**—a powerful feature that allows you to write flexible, reusable, and type-safe code. But what exactly are generics, and why should you care? Generics solve a fundamental problem: how to create components (classes, functions, interfaces) that work with multiple types without sacrificing type safety. Before generics, developers often used `Any` (Kotlin’s root type) to make code reusable, but this led to messy casting and runtime errors. Generics eliminate these issues by letting you define "placeholders" for types, ensuring the compiler checks types at compile time. Whether you’re building a simple data structure, a utility function, or a complex library, generics will make your code cleaner and more robust. In this guide, we’ll break down Kotlin generics from the ground up, with simple examples and clear explanations tailored for beginners.

Table of Contents

What Are Generics? (and Why Do They Matter?)

The Problem Without Generics

Imagine you want to create a Box class to store a single item. Without generics, you might hardcode the type (e.g., BoxInt for integers, BoxString for strings), but this duplicates code. Alternatively, you could use Any (Kotlin’s equivalent of Object in Java) to make the Box work with any type:

class Box(private val item: Any) {
    fun get(): Any = item
}

fun main() {
    val numberBox = Box(42)
    val textBox = Box("Hello")

    // Risky! Casting is required, and the compiler can't check types here.
    val number: Int = numberBox.get() as Int  // Works...
    val text: String = textBox.get() as String  // Works...
    val invalid: Int = textBox.get() as Int  // Compiles, but crashes at runtime!
}

This code “works,” but it’s dangerous:

  • You must manually cast the result of get() to the desired type.
  • The compiler can’t catch mistakes (like casting a String to Int), leading to ClassCastException at runtime.

The Solution With Generics

Generics fix this by letting you define a “type placeholder” (called a type parameter) when creating a component. You specify the actual type later when using the component, and the compiler enforces type safety.

Here’s the generic version of Box:

// Define a generic class with type parameter `T`
class Box<T>(private val item: T) {
    fun get(): T = item  // No casting needed!
}

fun main() {
    val numberBox = Box<Int>(42)  // Specify type: T = Int
    val textBox = Box<String>("Hello")  // T = String

    val number: Int = numberBox.get()  // Safe: Compiler knows T is Int
    val text: String = textBox.get()  // Safe: Compiler knows T is String

    // Error! Compiler catches the mistake at compile time.
    val invalid: Int = textBox.get()  // "Type mismatch: inferred type is String but Int was expected"
}

Now:

  • No casting is needed—get() returns T, the type you specified.
  • The compiler checks types, so invalid casts are caught early.
  • The Box class works with any type, but remains type-safe.

Understanding Type Parameters

Syntax Basics

Generics use type parameters (placeholders for types) enclosed in angle brackets < >. For example:

  • class Box<T>: T is the type parameter for the Box class.
  • fun <T> printItem(item: T): T is the type parameter for the printItem function.

When using a generic component, you replace the type parameter with a type argument (a concrete type like Int, String, or User). For example:

  • Box<Int>: Int is the type argument for T.

Naming Conventions

Type parameters are usually named with single uppercase letters to distinguish them from regular types. Common conventions:

  • T: Generic type (e.g., T for “Type”).
  • E: Element (e.g., in collections like List<E>).
  • K: Key (e.g., in Map<K, V>).
  • V: Value (e.g., in Map<K, V>).
  • R: Return type (e.g., in functions like fun <T, R> transform(t: T): R).

You can use any name (e.g., MyType), but single letters are standard for brevity.

Generic Classes: A Closer Look

A generic class is a class that declares one or more type parameters. Let’s dive deeper with examples.

Defining a Generic Class

To define a generic class, add type parameters after the class name:

// Generic class with one type parameter T
class Box<T>(val item: T) {
    fun get(): T = item
    fun set(newItem: T) { /* ... */ }
}

// Generic class with two type parameters K and V
class Pair<K, V>(val first: K, val second: V) {
    // ...
}

Creating Instances with Type Arguments

When creating an instance of a generic class, specify the type arguments in angle brackets. Kotlin often infers the type arguments for you, so you can omit them if the context is clear:

fun main() {
    // Explicit type argument: T = String
    val stringBox = Box<String>("Hello")

    // Type inferred: T = Int (since 42 is an Int)
    val intBox = Box(42)  // Same as Box<Int>(42)

    // Two type arguments: K = String, V = Int
    val pair = Pair("age", 25)  // Inferred as Pair<String, Int>
}

Type Safety in Action

The compiler uses type arguments to enforce type safety. For example, you can’t store a String in a Box<Int>:

val intBox = Box(42)
intBox.set("Oops")  // Compile error: "Type mismatch: inferred type is String but Int was expected"

No more runtime ClassCastException—mistakes are caught at compile time!

Generic Functions

Generics aren’t limited to classes. You can also define generic functions that work with multiple types.

Defining Generic Functions

To create a generic function, add type parameters before the function name. The type parameters can be used in the function’s parameters, return type, or body:

// Generic function with type parameter T
fun <T> printItem(item: T) {
    println("Item: $item")
}

// Generic function with return type T
fun <T> createBox(item: T): Box<T> {
    return Box(item)
}

Example: A Reusable Swap Function

Suppose you want a function to swap two elements in a list. Without generics, you’d need separate functions for List<Int>, List<String>, etc. With generics, you can write a single function that works for any list:

// Generic function to swap elements at positions i and j in a mutable list
fun <T> swap(list: MutableList<T>, i: Int, j: Int) {
    if (i !in 0 until list.size || j !in 0 until list.size) return
    val temp = list[i]
    list[i] = list[j]
    list[j] = temp
}

fun main() {
    val numbers = mutableListOf(1, 2, 3)
    swap(numbers, 0, 2)  // Swaps 1 and 3 → [3, 2, 1]

    val words = mutableListOf("apple", "banana")
    swap(words, 0, 1)  // Swaps "apple" and "banana" → ["banana", "apple"]
}

This swap function works for MutableList<Int>, MutableList<String>, or any other MutableList type—thanks to generics!

Type Constraints: Restricting Type Parameters

Sometimes, you want a generic component to work only with specific types (e.g., numbers, or classes that implement a certain interface). Type constraints let you restrict the allowed type arguments.

Single Constraints with :

Use : to specify a constraint. For example, to create a function that sums two numbers, you can restrict T to Number (Kotlin’s supertype for all numeric types like Int, Double, etc.):

// T must be a subtype of Number (Int, Double, Float, etc.)
fun <T : Number> sum(a: T, b: T): Double {
    return a.toDouble() + b.toDouble()
}

fun main() {
    sum(2, 3)  // T = Int → 5.0
    sum(2.5, 3.5)  // T = Double → 6.0
    sum("2", "3")  // Compile error: "Type argument is not within its bounds: should be subtype of Number"
}

Multiple Constraints with where

If a type parameter needs to satisfy multiple constraints (e.g., implement two interfaces), use the where clause:

// Define interfaces for constraints
interface Printable { fun print(): String }
interface Comparable<T> { fun compareTo(other: T): Int }

// T must implement both Printable and Comparable<T>
fun <T> process(item: T) where T : Printable, T : Comparable<T> {
    println(item.print())
    // ... use compareTo ...
}

Now process only accepts types that are both Printable and Comparable<T>.

Variance: Covariance and Contravariance

Variance is a advanced but critical concept: it defines how generic types relate to their type arguments. For example, is List<String> a subtype of List<Any>? Spoiler: It depends on variance!

Covariance (out): Producers

A generic type is covariant if it preserves the subtyping of its type arguments. Use the out keyword to mark a type parameter as covariant.

Covariant types can only produce (return) values of type T, not consume (accept) them.

Example: Kotlin’s List is covariant (List<out T>). You can return elements from a List, but you can’t add elements to it (since adding would “consume” T):

// Covariant interface: can produce T (return T), but not consume T (accept T as input)
interface Producer<out T> {
    fun produce(): T
}

fun main() {
    // String is a subtype of Any
    val stringProducer: Producer<String> = object : Producer<String> {
        override fun produce() = "Hello"
    }

    // Since Producer is covariant (out T), Producer<String> is a subtype of Producer<Any>
    val anyProducer: Producer<Any> = stringProducer  // Safe!

    println(anyProducer.produce())  // Output: "Hello" (still a String)
}

Why is this safe? Because Producer<out T> only returns T, so even if you treat it as Producer<Any>, the returned value is still a valid Any.

Contravariance (in): Consumers

A generic type is contravariant if it reverses the subtyping of its type arguments. Use the in keyword to mark a type parameter as contravariant.

Contravariant types can only consume (accept) values of type T, not produce (return) them.

Example: Kotlin’s Comparable is contravariant (Comparable<in T>). A Comparable<Number> can compare Int (a subtype of Number):

// Contravariant interface: can consume T (accept T as input), but not produce T (return T)
interface Consumer<in T> {
    fun consume(item: T)
}

fun main() {
    // Any is a supertype of String
    val anyConsumer: Consumer<Any> = object : Consumer<Any> {
        override fun consume(item: Any) {
            println("Consumed: $item")
        }
    }

    // Since Consumer is contravariant (in T), Consumer<Any> is a subtype of Consumer<String>
    val stringConsumer: Consumer<String> = anyConsumer  // Safe!

    stringConsumer.consume("Hello")  // Works: "Hello" is an Any
}

Why is this safe? Because Consumer<in T> only accepts T, so a Consumer<Any> can safely accept a String (since String is an Any).

The “Producer-Out, Consumer-In” Mnemonic

To remember variance:

  • out (covariant): For types that produce values (T is returned). Think: “Producer → Out”.
  • in (contravariant): For types that consume values (T is accepted as input). Think: “Consumer → In”.

Type Erasure: What Happens at Runtime?

At runtime, Kotlin (like Java) uses type erasure: the compiler removes type parameters, so generic types are treated as raw types (e.g., Box<T> becomes just Box).

This means you can’t check the type of a generic instance at runtime:

val box = Box("Hello")
if (box is Box<String>) {  // Compile error: "Cannot check for instance of erased type Box<String>"
    // ...
}

Instead, use a star projection (Box<*>) to represent an unknown type:

if (box is Box<*>) {  // Allowed: checks if it's a Box of any type
    // ...
}

Reified Type Parameters (A Workaround)

Kotlin lets you bypass type erasure for generic functions using reified type parameters (marked with reified) and inline functions. This preserves the type parameter at runtime:

// Inline function with reified type parameter T
inline fun <reified T> isType(value: Any): Boolean {
    return value is T  // Now allowed!
}

fun main() {
    println(isType<String>("Hello"))  // true
    println(isType<Int>("Hello"))     // false
}

inline functions are copied into the calling code at compile time, so the reified type T is preserved.

Common Pitfalls to Avoid

  1. Overusing Any instead of generics: This defeats the purpose of type safety. Use generics instead!
  2. Ignoring variance rules: Using out when you need in, or vice versa, leads to compile errors or unsafe code.
  3. Assuming type parameters exist at runtime: Remember type erasure—you can’t use T::class or is T unless using reified type parameters.
  4. Forgetting type constraints: If your generic function only works with Number, add a where T : Number constraint to catch errors early.

Conclusion

Generics are a cornerstone of Kotlin (and modern programming) that enable you to write reusable, type-safe code. By defining type parameters, you create components that work with multiple types while letting the compiler enforce type correctness.

Key takeaways:

  • Type parameters (T, K, V) act as placeholders for types.
  • Generic classes/functions work with any type specified by the user.
  • Variance (out for producers, in for consumers) ensures safe subtyping of generic types.
  • Type erasure removes type parameters at runtime, but reified type parameters (with inline functions) help workaround this.

With generics, you’ll write cleaner, more flexible code—and avoid runtime errors caused by unchecked casts.

References