Table of Contents
- What Are Generics? (and Why Do They Matter?)
- Understanding Type Parameters
- Generic Classes: A Closer Look
- Generic Functions
- Type Constraints: Restricting Type Parameters
- Variance: Covariance and Contravariance
- Type Erasure: What Happens at Runtime?
- Common Pitfalls to Avoid
- Conclusion
- References
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
StringtoInt), leading toClassCastExceptionat 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()returnsT, the type you specified. - The compiler checks types, so invalid casts are caught early.
- The
Boxclass 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>:Tis the type parameter for theBoxclass.fun <T> printItem(item: T):Tis the type parameter for theprintItemfunction.
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>:Intis the type argument forT.
Naming Conventions
Type parameters are usually named with single uppercase letters to distinguish them from regular types. Common conventions:
T: Generic type (e.g.,Tfor “Type”).E: Element (e.g., in collections likeList<E>).K: Key (e.g., inMap<K, V>).V: Value (e.g., inMap<K, V>).R: Return type (e.g., in functions likefun <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 (Tis returned). Think: “Producer → Out”.in(contravariant): For types that consume values (Tis 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
- Overusing
Anyinstead of generics: This defeats the purpose of type safety. Use generics instead! - Ignoring variance rules: Using
outwhen you needin, or vice versa, leads to compile errors or unsafe code. - Assuming type parameters exist at runtime: Remember type erasure—you can’t use
T::classoris Tunless usingreifiedtype parameters. - Forgetting type constraints: If your generic function only works with
Number, add awhere T : Numberconstraint 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 (
outfor producers,infor consumers) ensures safe subtyping of generic types. - Type erasure removes type parameters at runtime, but
reifiedtype parameters (withinlinefunctions) help workaround this.
With generics, you’ll write cleaner, more flexible code—and avoid runtime errors caused by unchecked casts.